# 완성작
# TODO LIST
저번 포스팅에서는 exoPlayer에 노래가 재생되겠끔하는 것, 재생목록에 뮤직 리스트가 보여지게 했다. 그러나 next나 prev버튼을 눌렀을때는 별 다른 동작을 하지 않기때문에 해당 행위에 대해서 동작을 정의해줘야 한다. 추가적으로 Model을 정의해 사용하기 때문에 그 흐름 순서에 대해서 잘 정리 하도록 하겠다.
# PlayerModel 사용하기
이게 정확히 ViewModel의 개념인지는 모르겠지만, 앱 내에서 사용되는 모델을 정리해서 사용하는 데이터 클래스다.
package com.example.part3.musicplayer
data class PlayerModel(
private val playMusicList : List<MusicModel> = emptyList(),
var currentPosition : Int = -1,
var isWatchingPlayListView : Boolean = true
){
/*
현재 뮤직모델이 재생중인지의 여부를 알기 위해서
copy를 사용하여 깊은복사된 data class를 반환시켜줌
*/
fun getAdapterModels() : List<MusicModel> {
return this.playMusicList.mapIndexed { index, musicModel ->
val newItem = musicModel.copy(
isPlaying = index == currentPosition
)
newItem
}
}
}
next와 prev를 위해 position을 구하는 함수들에 대해서는 순서대로 추가하도록 하겠다. PlayerModel 데이터 클래스를 만들고, 여기서 음악 리스트와 현재 재생중인 포지션, 음악목록을 보여지고 있는지를 관리하도록 한다.
// 전역객체 생성
private var model : PlayerModel = PlayerModel()
private fun getMusicsFromServer() {
val retrofit = Retrofit.Builder()
.baseUrl("https://run.mocky.io")
.addConverterFactory(GsonConverterFactory.create())
.build()
retrofit.create(RetrofitService::class.java).getMusics()
.enqueue(object : Callback<MusicsDto>{
override fun onResponse(call: Call<MusicsDto>, response: Response<MusicsDto>) {
if(response.isSuccessful.not()) return
// 맵퍼를 통해 모델을 만들고 map반복문을 사용하여 모델리스트를 최종적으로 반환
response.body()?.let { musicsDto ->
model = musicsDto.mapper()
setMusicList(model.getAdapterModels())
listAdapter.submitList(model.getAdapterModels())
}
}
override fun onFailure(call: Call<MusicsDto>, t: Throwable) {
}
})
}
fun MusicsDto.mapper() : PlayerModel =
PlayerModel(
playMusicList = this.musics.mapIndexed { index, musicEntity ->
musicEntity.mapper(index.toLong())
}
)
API 호출을 통해 데이터를 받아오면 전역에 선언된 model에 맵퍼를 통해서 할당해 준다.
# Next, Prev 버튼 구현하기
다음 또는 이전 버튼이 눌리면, 다음노래가 뭔지를 찾아낸후 재생시키는 것이다. 다음 노래가 뭔지를 찾고, 그 노래의 인덱스를 exoPlayer에 알려준 후 재생시킨후, model(PlayerModel)에서도 currentMusicPosition을 업데이트 해준다. 그리고 나서 exoPlayer에 리스너를 추가해 음악이 바뀌었다는 것에 콜백함수를 등록하여 리사이클러뷰에 현재 재생중인 음악의 뒷 배경이 바뀌도록 해주면 된다.
## Next, Prev Button 리스너
private fun initPlayControlButtons(mBinding: FragmentPlayerBinding) {
mBinding.playerControlImageView.setOnClickListener {
//생략
}
mBinding.skipPreviousImageView.setOnClickListener {
val prevMusic = model.prevMusic() ?: return@setOnClickListener
playMusic(prevMusic)
}
mBinding.skipNextImageView.setOnClickListener {
val nextMusic = model.nextMusic() ?: return@setOnClickListener
playMusic(nextMusic)
}
}
이렇게 prevMusic또는 nextMusic의 MusicModel을 찾아 낸후 playMusic이라는 함수를 만들어 인자로 넘겨준다.
private fun playMusic(musicModel: MusicModel) {
model.updateCurrentPosition(musicModel)
exoPlayer?.seekTo(model.currentPosition, 0)
exoPlayer?.play()
}
playMusic은 다음과 같이 동작하는데, model에 udpateCurrentPosistion을 통해서 재생중인 노래의 인덱스를 관리할 수 있도록 한다.
## PlayerModel에 기능 추가
data class PlayerModel(
private val playMusicList : List<MusicModel> = emptyList(),
var currentPosition : Int = -1,
var isWatchingPlayListView : Boolean = true
){
/*
현재 뮤직모델이 재생중인지의 여부를 알기 위해서
copy를 사용하여 깊은복사된 data class를 반환시켜줌
*/
fun getAdapterModels() : List<MusicModel> {
return this.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.size-1 else currentPosition -1
return playMusicList[currentPosition]
}
fun currentMusicModel(): MusicModel? {
if (playMusicList.isEmpty()) return null
return playMusicList[currentPosition]
}
}
프래그먼트에서 쓰인 함수들을 정의해준다.
## EXOPLAYER
private fun initPlayer(mBinding: FragmentPlayerBinding) {
// 플레이어 초기화
context?.let {
exoPlayer = SimpleExoPlayer.Builder(it).build()
}
mBinding.playerView.player = exoPlayer
// 플레이어 리스너
binding?.let{ binding ->
exoPlayer?.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if(isPlaying) {
// 재생상태이기에 퍼스 아이콘
binding.playerControlImageView.setImageResource(R.drawable.ic_baseline_pause_48)
}else {
binding.playerControlImageView.setImageResource(R.drawable.ic_baseline_play_48)
}
}
// item이 바뀌는 경우 리사이클러뷰 초기화 해줘야함 ( currentPositon )
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
val newIndex = mediaItem?.mediaId ?: return
model.currentPosition = newIndex.toInt()
updatePlayerView(model.currentMusicModel())
listAdapter.submitList(model.getAdapterModels())
}
// 재생 상태 버퍼링중 완료중 ..
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
updateSeek()
}
})
}
}
onMediaItemTransition을 통해서 재생중인 노래가 변경됐음을 감지했을때 실행되는 콜백함수에 listAdapter에 submit을 통해 재생중인 노래의 배경색이 바뀌도록 해준다.
아래와 같은 동작이 된다.
## 재생UI 변경
private fun updatePlayerView(currentMusicModel: MusicModel?) {
currentMusicModel ?: return
// 메인뷰UI 바꿔주기
binding?.let { mBinding ->
mBinding.trackTextView.text = currentMusicModel.track
mBinding.artistTextView.text = currentMusicModel.artist
Glide.with(mBinding.coverImageView.context)
.load(currentMusicModel.coverUrl)
.into(mBinding.coverImageView)
}
}
onMediaItemTransition 안에 위 함수를 정의해줌으로써, 노래가 바뀔때 메인뷰 UI도 업데이트 될 수 있도록 한다.
# SeekBar 연동구현하기
private fun initPlayer(mBinding: FragmentPlayerBinding) {
//생략
binding?.let{ binding ->
exoPlayer?.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
//생략
}
// item이 바뀌는 경우 리사이클러뷰 초기화 해줘야함 ( currentPositon )
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
//생략
}
// 재생 상태 버퍼링중 완료중 ..
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
updateSeek()
}
})
}
}
노래의 재생 상태 재생정지 재생중 재생완료 등등에 대한 상태를 player의 콜백 리스너를 통해서 받는다. 이후 updateSeek함수를 통해서 SeekBar의 UI를 조작해 줄 수 있도록 하자.
private fun updateSeek() {
val player = exoPlayer ?:return
//전체 길이
val duration = if(player.duration >= 0 ) player.duration else 0
val position = player.currentPosition
updateSeekUi(duration,position)
val state = player.playbackState
// state가 재생중이 아닐때에 ui를 초기화 하지 않도록 하도록
view?.removeCallbacks(updateSeekRunnable)
// 재생중일때 실시간으로 SeekBar 변경하기 postDelayed를 통해서
if( state != Player.STATE_IDLE && state != Player.STATE_ENDED) {
view?.postDelayed(updateSeekRunnable,1000)
}
}
postDelayed를 통해 1초마다 UI를 그려 줄 수 있도록 한다. Runnable을 작성해준다.
private val updateSeekRunnable = Runnable {
updateSeek()
}
다시 updateSeek함수를 재호출하게 하여 UI를 그려주도록 하면 된다.
private fun updateSeekUi(duration: Long, position: Long) {
binding?.let { mBinding ->
mBinding.playListSeekBar.max = (duration / 1000).toInt() //전체길이의 seekBar
mBinding.playListSeekBar.progress = (position / 1000).toInt() //현재 진행상태
mBinding.playerSeekBar.max = (duration / 1000).toInt()
mBinding.playerSeekBar.progress = (position / 1000).toInt()
mBinding.currentPlayTimeTextView.text = String.format("%02d:%02d",
TimeUnit.MINUTES.convert(position,TimeUnit.MILLISECONDS),
(position/1000) % 60)
mBinding.totalPlayTimeTextView.text = String.format("%02d:%02d",
TimeUnit.MINUTES.convert(duration,TimeUnit.MILLISECONDS),
(duration/1000) % 60)
}
}
UI를 그려주는 함수이다.
아래와 같이 SeekBar가 바뀌는 걸 볼 수 있다.
# 마무리
이번 강의를 보면서 공부하면서, 뭔가 구현되는 것들이 많고 분리되는 것도 많아서 어려움을 느꼈다. 그전에는 onCreated함수에 다 때려박게 코딩했는데.. 사실 안드로이드 공식문서에서는 가장 권장하지 않는 방법이 이었다. 그치만 시작부터 아키텍처를 사용하면서 의존성 분리를 해버리면 왜 그게 필요한지도 모르는데.. 지금 나는 딱 그런 분리에 필요성에 대해서 조금씩 느끼고 있었던 터라 이번 강의가 어렵지만 재밌었다. 그래도 완벽 100%이해는 되지 않았지만. 내 목표가 그게 아니니까...! 남은 강의도 완강 해야겠다.