음악 스트리밍 앱 (ExoPlayer, Retrofit, androidx.contraintLayout.widget.Group)
주요 기능
- Retrofit 을 이용해 서버에서 음악 받아와 재생 목록 구성
- 재생 목록을 클릭하여 ExoPlayer 를 이용해 음악을 재생
- 이전/다음 곡 재생, UI 업데이트
- 재생 목록 화면과 플레이 화면 간 전환
- seekBar 를 커스텀 하여 원하는 UI 로 표시
사용 기술
- ExoPlayer
- Retrofit
- androidx.contraintLayout.widget.Group
라이브러리 추가
// 레트로핏
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// 글라이드
implementation 'com.github.bumptech.glide:glide:4.12.0'
// exoPlayer
implementation 'com.google.android.exoplayer:exoplayer:2.16.1'
// 뷰바인딩도 추가해줌
viewBinding {
enabled = true
}
레이아웃 구성
layout/activity_main.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"
tools:context=".MainActivity">
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
layout/fragment_player.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">
<androidx.constraintlayout.widget.Group
android:id="@+id/playerViewGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="trackTextView, artistTextView, coverImageCardView, bottomBackgroundView, playerSeekBar, playTimeTextView, totalTimeTextView"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Group
android:id="@+id/playListViewGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="titleTextView, playListRecyclerView, playListSeekBar" />
<View
android:id="@+id/topBackgroundView"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/background"
app:layout_constraintBottom_toTopOf="@id/bottomBackgroundView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_weight="3" />
<View
android:id="@+id/bottomBackgroundView"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/white_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topBackgroundView"
app:layout_constraintVertical_weight="2" />
<TextView
android:id="@+id/trackTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Got my number" />
<TextView
android:id="@+id/titleTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="재생목록"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/artistTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:textColor="@color/gray_aa"
android:textSize="15sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/trackTextView"
tools:text="MONSTA-X" />
<androidx.cardview.widget.CardView
android:id="@+id/coverImageCardView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="36dp"
android:layout_marginEnd="36dp"
android:translationY="50dp"
app:cardCornerRadius="5dp"
app:cardElevation="10dp"
app:layout_constraintBottom_toBottomOf="@id/topBackgroundView"
app:layout_constraintDimensionRatio="H, 1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:id="@+id/coverImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:background="@color/purple_200" />
</androidx.cardview.widget.CardView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/playListRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/playerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleTextView" />
<com.google.android.exoplayer2.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="0dp"
android:layout_height="100dp"
android:layout_marginTop="16dp"
android:alpha="0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:use_controller="false" />
<SeekBar
android:id="@+id/playerSeekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="50dp"
android:layout_marginEnd="50dp"
android:layout_marginBottom="30dp"
android:maxHeight="4dp"
android:minHeight="4dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:progressDrawable="@drawable/player_seek_background"
android:thumb="@drawable/player_seek_thumb"
app:layout_constraintBottom_toTopOf="@id/playerView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:progress="40" />
<TextView
android:id="@+id/playTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/purple_200"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="@id/playerSeekBar"
app:layout_constraintTop_toBottomOf="@id/playerSeekBar"
tools:text="0:00" />
<TextView
android:id="@+id/totalTimeTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/gray_97"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="@id/playerSeekBar"
app:layout_constraintTop_toBottomOf="@id/playerSeekBar"
tools:text="0:00" />
<SeekBar
android:id="@+id/playListSeekBar"
android:layout_width="0dp"
android:layout_height="2dp"
android:clickable="false"
android:paddingStart="0dp"
android:paddingEnd="0dp"
android:progressTint="@color/purple_200"
android:thumbTint="@color/purple_200"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/playerView"
tools:progress="40" />
<ImageView
android:id="@+id/playControlImageView"
android:layout_width="50dp"
android:layout_height="50dp"
android:src="@drawable/ic_baseline_play_arrow_48"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/playerView"
app:tint="@color/black" />
<ImageView
android:id="@+id/skipNextImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_baseline_skip_next_48"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.2"
app:layout_constraintStart_toEndOf="@id/playControlImageView"
app:layout_constraintTop_toTopOf="@id/playerView"
app:tint="@color/black" />
<ImageView
android:id="@+id/skipPrevImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_baseline_skip_previous_48"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/playControlImageView"
app:layout_constraintHorizontal_bias="0.8"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/playerView"
app:tint="@color/black" />
<ImageView
android:id="@+id/playlistImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="24dp"
android:src="@drawable/ic_baseline_playlist_play_48"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/playerView"
app:tint="@color/black" />
</androidx.constraintlayout.widget.ConstraintLayout>
플레이리스트 화면과 플레이 화면을 따로 나누지 않고 한 프래그먼트 안에서 Group으로 묶어 플레이리스트 아이콘을 누를 때 관련되 뷰들만 보여지도록 하였고, View 를 두개 주어 배경색을 지정해주고 곡 제목, 아티스트명을 TextView 로 주고 플레이 화면에서 플레이리스트 화면으로 화면 전환시 보일 재생목록을 TextView 로 주었다.
커버이미지는 CardView 로 감싸 cardElevation 과 cardCornerRadius 속성을 사용해 꾸며주고 하단에 ExoPlayer 를 통해 음악을 재생 or 일시정지, 이전 or 다음 곡을 재생할 수 있는 UI 를 만들어주었다.
플레이 화면에서 보일 SeekBar 와 플레이리스트 화면에서 보일 SeekBar 를 각각 만들어주었고, 플레이리스트에서 사용될 RecyclerView 도 만들어 주었다.
topBackgroundView 의 app:layout_contraintVertical_weight="3" 의 가중치를 주고
bottomBackgroundView 의 app:layout_contraintVertical_weight="2" 의 가중치를 주어 3:2 비율로 맞춰주었다.
coverImageCardView 에서 이미지커버를 조금 아래로 내리고 싶으나 margin 은 (-)가 먹히지 않기 때문에 translationY 속성을 주어 내려주었다.
SeekBar 는 자체적으로 padding 값이 들어가 있기 때문에 paddingStart 와 paddingEnd 속성에 0dp 를 주어 패딩값을 없애주었다.
SeekBar 커스텀
drawable/player_seek_background.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="2dp"/>
<solid android:color="@color/seek_background"/>
</shape>
</item>
<item android:id="@+id/progress">
<clip>
<shape>
<corners android:radius="2dp"/>
<stroke android:width="2dp"
android:color="@color/purple_200"/>
<solid android:color="@color/purple_200"/>
</shape>
</clip>
</item>
</layer-list>
drawable/player_seek_thumb.xml
<?xml version="1.0" encoding="utf-8"?>
<shape android:shape="rectangle" xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/purple_200"/>
<size android:height="4dp"
android:width="4dp"/>
</shape>
어댑터에서 사용할 데이터 양식
layout/item_music.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="wrap_content">
<ImageView
android:id="@+id/itemCoverImageView"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/itemTrackTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:textColor="@color/white"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/itemCoverImageView"
app:layout_constraintTop_toTopOf="@id/itemCoverImageView"
tools:text="Got my number" />
<TextView
android:id="@+id/itemArtistTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="1"
android:singleLine="true"
android:textColor="@color/gray_aa"
android:textSize="13sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/itemCoverImageView"
app:layout_constraintTop_toBottomOf="@id/itemTrackTextView"
tools:text="MONSTA-X" />
</androidx.constraintlayout.widget.ConstraintLayout>
MainActivity, PlayerFragment
MainActivity.kt
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager.beginTransaction()
.replace(R.id.fragmentContainer, PlayerFragment.newInstance())
.commit()
}
}
PlayFragment.kt
class PlayerFragment : Fragment(R.layout.fragment_player) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
companion object {
fun newInstance(): PlayerFragment {
return PlayerFragment()
}
}
}
Fragment 를 상속한 PlayerFragment 파일을 생성하고 메인 액티비티의 fragmentContainer 에 할당해주어 PlayerFragment 프래그먼트가 최초 실행 시 보이도록 설정해주었다.
메인 액티비티에서 값을 하나 만들고 newInstance 로 인자를 넘겨주고 apply 함수를 통해 argument 에 쉽게 추가할 수 있도록 하기 위해 newInstance 함수를 따로 만들어 추가해주었다.
음악 목록 api 서버에서 받아오기
먼저 retrofit 을 사용하기 위해 인터넷 권한을 추가해준다.
<uses-permission android:name="android.permission.INTERNET"/>
유튜브 프로젝트에서는 모델 하나로 서버에서 내려오는 객체와 뷰에서 사용하는 객체를 동일시하여 VideoModel 을 사용했는데, 이번 프로젝트에서는 서버에서 내려오는 데이터 그 자체인 MusicEntity 와 뷰에서 사용하는 MusicModel 두개의 모델을 분리해주고 mapper 를 통해서 매핑해줄 것이다.
service/MusicEntity.kt
import com.google.gson.annotations.SerializedName
data class MusicEntity(
@SerializedName("track") val track: String,
@SerializedName("streamUrl") val streamUrl: String,
@SerializedName("artist") val artist: String,
@SerializedName("cover") val coverUrl: String
)
service/MusicDto.kt
data class MusicDto(
val musics: List<MusicEntity>
)
service/MusicService
interface MusicService {
@GET("/v3/9f853a2a-62b3-48f4-91d5-65eabd0b32f5")
fun listMusics(): Call<MusicDto>
}
MusicModel.kt
data class MusicModel(
val id: Long,
val track: String,
val streamUrl: String,
val artist: String,
val coverUrl: String,
val isPlaying: Boolean = false
)
MusicModelMapper.kt
fun MusicEntity.mapper(id: Long): MusicModel =
MusicModel(
id = id,
streamUrl = streamUrl,
coverUrl = coverUrl,
track = track,
artist = artist
)
fun MusicDto.mapper(): PlayerModel =
PlayerModel(
playMusicList = musics.mapIndexed { index, musicEntity ->
musicEntity.mapper(index.toLong())
}
)
MusicEntity 에서 MusicModel 로 바꾸기 위해 MusicDto를 확장시켜 mapper 를 이용해 바로 모델로 바꾸어 사용할 수 있게 한다.
PlayerModel.kt
package com.example.musicplayer
data class PlayerModel(
private val playMusicList: List<MusicModel> = emptyList(),
var currentPosition: Int = -1,
var isWatchingPlayListView: Boolean = true
) {
fun getAdapterModels(): List<MusicModel> {
return playMusicList.mapIndexed { index, musicModel ->
val newItem = musicModel.copy(
isPlaying = index == currentPosition
)
newItem
}
}
fun updateCurrentPosition(musicModel: MusicModel) {
currentPosition = playMusicList.indexOf(musicModel)
}
fun nextMusic(): MusicModel? {
if (playMusicList.isEmpty()) return null
currentPosition = if ((currentPosition + 1) == playMusicList.size) 0 else currentPosition + 1
return playMusicList[currentPosition]
}
fun prevMusic(): MusicModel? {
if (playMusicList.isEmpty()) return null
currentPosition = if ((currentPosition - 1) < 0) playMusicList.lastIndex else currentPosition - 1
return playMusicList[currentPosition]
}
fun currentMusicModel(): MusicModel? {
if (playMusicList.isEmpty()) return null
return playMusicList[currentPosition]
}
}
PlayerFragment 가 사용하는 PlayerModel 클래스를 생성해 모델 클래스에 데이터 관리 위임을 해주도록 하였다.
PlayerFragment.kt
private fun getMusicListFromService() {
val retrofit = Retrofit.Builder()
.baseUrl("https://run.mocky.io")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(MusicService::class.java)
.also {
it.listMusics()
.enqueue(object : Callback<MusicDto> {
override fun onResponse(call: Call<MusicDto>, response: Response<MusicDto>) {
Log.d("PlayerFragment", "${response.body()}")
response.body()?.let { musicDto ->
model = musicDto.mapper()
setMusicList(model.getAdapterModels())
playListAdapter.submitList(model.getAdapterModels())
}
}
override fun onFailure(call: Call<MusicDto>, t: Throwable) {
}
})
}
}
플레이리스트와 플레이 화면 간 전환
class PlayerFragment : Fragment(R.layout.fragment_player) {
private var model: PlayerModel = PlayerModel()
private var binding: FragmentPlayerBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val fragmentPlayerBinding = FragmentPlayerBinding.bind(view)
binding = fragmentPlayerBinding
initPlayListButton(fragmentPlayerBinding)
}
private fun initPlayListButton(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.playlistImageView.setOnClickListener {
// 서버에서 데이터가 다 불려오지 못했을 때 전환하지 않고 예외처리
if (model.currentPosition == -1) return@setOnClickListener
fragmentPlayerBinding.playerViewGroup.isVisible = model.isWatchingPlayListView
fragmentPlayerBinding.playListViewGroup.isVisible = model.isWatchingPlayListView.not()
model.isWatchingPlayListView = !model.isWatchingPlayListView
}
}
}
플레이리스트 띄우기
앞서 곡의 정보를 담고 있는 MusicModel 데이터 클래스를 생성해주었고 isPlaying 을 false 로 초기화 하여 현재 재생이 되고 있는지에 대한 상태를 나타내 주었다.
PlayListAdapter.kt
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
class PlayListAdapter(private val callback: (MusicModel) -> Unit) : ListAdapter<MusicModel, PlayListAdapter.ViewHolder> (diffUtil) {
inner class ViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
fun bind(item: MusicModel) {
val trackTextView = view.findViewById<TextView>(R.id.itemTrackTextView)
val artistTextView = view.findViewById<TextView>(R.id.itemArtistTextView)
val coverImageView = view.findViewById<ImageView>(R.id.itemCoverImageView)
trackTextView.text = item.track
artistTextView.text = item.artist
Glide.with(coverImageView.context)
.load(item.coverUrl)
.into(coverImageView)
if (item.isPlaying) {
itemView.setBackgroundColor(Color.GRAY)
} else {
itemView.setBackgroundColor(Color.TRANSPARENT)
}
itemView.setOnClickListener {
callback(item)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_music, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
currentList[position].also { musicModel ->
holder.bind(musicModel)
}
}
companion object {
val diffUtil = object : DiffUtil.ItemCallback<MusicModel>() {
override fun areItemsTheSame(oldItem: MusicModel, newItem: MusicModel): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: MusicModel, newItem: MusicModel): Boolean {
return oldItem == newItem
}
}
}
}
ListAdapter 를 상속해 어댑터를 구현해주었고, 생성자로 콜백함수를 받아 아이템 클릭시 콜백을 실행하도록 구현해주었다.
PlayerFragment.kt
private fun initRecyclerView(fragmentPlayerBinding: FragmentPlayerBinding) {
playListAdapter = PlayListAdapter {
// 음악 재생
playMusic(it)
}
fragmentPlayerBinding.playListRecyclerView.apply {
adapter = playListAdapter
layoutManager = LinearLayoutManager(context)
}
}
playListAdapter.submitList(model.getAdapterModels())
어댑터를 PlayerFragment의 레트로핏 부분에 붙여주면 플레이리스트가 뜨게 된다.
ExoPlayer 사용하기
PlayFragment.kt
private var player: SimpleExoPlayer? = null
변수를 정의하고 null 을 할당해주고
private fun setMusicList(modelList: List<MusicModel>) {
context?.let {
player?.addMediaItems(modelList.map { musicModel ->
MediaItem.Builder()
.setMediaId(musicModel.id.toString())
.setUri(musicModel.streamUrl)
.build()
})
player?.prepare()
}
}
빌드 시 context 가 필요하기 때문에 context 를 null 체크 해주고 빌드해준다.
addMediaItems 를 이용해 미디어 아이템을 추가해주었다.
서버에서 음악 정보를 받아오면 이를 통해 MusicModel 리스트를 받아 player 의 미디어 아이템으로 추가해주었다.
이때 MediaId 로 MusicModel 의 Id 를 설정해 구분 가능하게 사용할 수 있도록 해주었고, setUri 로 음악이 재생될 주소를 설정해주고 빌드하면 미디어 아이템이 생성된다. 이후 prepare 를 통해 준비해준다.
재생 / 일시정지 UI 전환
PlayFragment.kt
private fun initPlayView(fragmentPlayerBinding: FragmentPlayerBinding) {
context?.let {
player = SimpleExoPlayer.Builder(it).build()
}
fragmentPlayerBinding.playerView.player = player
binding?.let { binding ->
player?.addListener(object : Player.EventListener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
if (isPlaying) {
binding.playControlImageView.setImageResource(R.drawable.ic_baseline_pause_48)
} else {
binding.playControlImageView.setImageResource(R.drawable.ic_baseline_play_arrow_48)
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
val newIndex = mediaItem?.mediaId ?: return
model.currentPosition = newIndex.toInt()
updatePlayerView(model.currentMusicModel())
playListAdapter.submitList(model.getAdapterModels())
}
})
}
}
player 는 addListener 를 통해서 Player.EventListener 를 구현하는 익명 객체를 넣어주면 쉽게 구현할 수 있다.
이후 필요한 이벤트 리스너를 재정의하여 구현해주면 된다.
onIsPlayingChagned : 플레이어가 재생/일시정지 상태가 되면 재생/일시정지 버튼의 아이콘을 알맞게 보여주도록 한다.
onMediaItemTransition : 미디어 아이템이 바뀔 때 마다 Id로 지정했던 mediaId 를 가져와 recyclerView 를 갱신(재생하고 있는 곡은 회색 화면으로), 플레이어뷰를 갱신(커버이미지, 제목, 아티스트명 갱신) 한다.
재생/일시정지, 이전/다음 재생 액션 추가
PlayFragment.kt
private fun initPlayControlButton(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.playControlImageView.setOnClickListener {
val player = this.player ?: return@setOnClickListener
if (player.isPlaying) {
player.pause()
} else {
player.play()
}
}
fragmentPlayerBinding.skipNextImageView.setOnClickListener {
val nextMusic = model.nextMusic() ?: return@setOnClickListener
playMusic(nextMusic)
}
fragmentPlayerBinding.skipPrevImageView.setOnClickListener {
val prevMusic = model.prevMusic() ?: return@setOnClickListener
playMusic(prevMusic)
}
}
private fun playMusic(musicModel: MusicModel) {
model.updateCurrentPosition(musicModel)
player?.seekTo(model.currentPosition, 0)
player?.play()
}
재생, 재생완료, 버퍼링 등의 상태 변화시 seekBar
PlayFragment.kt
private val updateSeekRunnable = Runnable {
updateSeek()
}
private fun updateSeek() {
val player = this.player ?: return
val duration = if (player.duration >= 0) player.duration else 0
val position = player.currentPosition
updateSeekUi(duration, position)
val state = player.playbackState
view?.removeCallbacks(updateSeekRunnable)
// 재생중일 때 (재생중이 아니거나 and 재생이 끝나지 않은 경우)
if (state != Player.STATE_IDLE && state != Player.STATE_ENDED) {
view?.postDelayed(updateSeekRunnable, 1000)
}
}
updatdSeek 에서는 음악 길이 정보를 통해 seekBar UI 를 업데이트하고 1초마다 updateSeekRunnable 을 실행하도록 한다.
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
updateSeek()
}
initPlayView 의 addListener 에 onPlaybackStateChanged 리스너를 재정의해주어 재생 중일 때 seekBar 의 잔량과 상태를 갱신해 주도록 하였다.
TimeUnit
PlayFragment.kt
private fun updateSeekUi(duration: Long, position: Long) {
binding?.let { binding ->
binding.playListSeekBar.max = (duration / 1000).toInt()
binding.playListSeekBar.progress = (position / 1000).toInt()
binding.playerSeekBar.max = (duration / 1000).toInt()
binding.playerSeekBar.progress = (position / 1000).toInt()
binding.playTimeTextView.text = String.format("%02d:%02d",
TimeUnit.MINUTES.convert(position, TimeUnit.MILLISECONDS),
(position / 1000) % 60)
binding.totalTimeTextView.text = String.format("%02d:%02d",
TimeUnit.MINUTES.convert(duration, TimeUnit.MILLISECONDS),
(position / 1000) % 60)
}
}
updateSeekUi 에서는 전체 길이와 현재 위치로 seekBar 를 갱신한다. max 와 progress 를 원래 위치 값에 1000을 나누어 너무 큰 값이 되지 않게 해주고, 시간을 표시하는 텍스트는 String.format 메서드를 사용해주었다.
seekBar 의 progress 이동
PlayFragment.kt
private fun initSeekBar(fragmentPlayerBinding: FragmentPlayerBinding) {
fragmentPlayerBinding.playerSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) {}
override fun onStartTrackingTouch(p0: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {
player?.seekTo((seekBar.progress * 1000).toLong())
}
})
fragmentPlayerBinding.playListSeekBar.setOnTouchListener { view, motionEvent ->
false
}
}
setOnSeekBarChangeListener 리스너를 사용해 seekBar 를 터치하여 움직이고 손을 뗼 때 해당 위치의 progress 를 플레이어에 설정해 구현해준다.
음악 재생시 플레이 화면에 제목 아티스트명, 이미지커버 업데이트
PlayFragment.kt
private fun updatePlayerView(currentMusicModel: MusicModel?) {
currentMusicModel ?: return
binding?.let { binding ->
binding.trackTextView.text = currentMusicModel.track
binding.artistTextView.text = currentMusicModel.artist
Glide.with(binding.coverImageView)
.load(currentMusicModel.coverUrl)
.into(binding.coverImageView)
}
}
생명주기에 따른 재생 관리
PlayFragment.kt
override fun onStop() {
super.onStop()
player?.pause()
view?.removeCallbacks(updateSeekRunnable)
}
override fun onDestroy() {
super.onDestroy()
binding = null
player?.release()
view?.removeCallbacks(updateSeekRunnable)
}
백그라운드로 나갔는데 계속 재생 등의 의도치 않은 작업들을 처리하기 위해 생명주기를 활용하였다.
사용자가 백그라운드로 나가거나 다른 앱을 사용할 경우 재생을 멈추고 seekBar 를 업데이트하는 콜백을 제거한다.
앱이 완전히 종료되는 경우 바인딩 해제, 플레이어 해제, 콜백을 제거해준다.
완성 화면