완성 화면
주요 기능
사용 기술
build.gradle
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'
implementation 'com.google.android.exoplayer:exoplayer:2.13.3'
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="좋은 사람" />
<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="토이" />
<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>
Adapter 에서 사용할 데이터 양식
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
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
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()
}
}
}
메인 액티비티에서 값을 하나 만들고 newInstance 로 인자를 넘겨주고 apply 함수를 통해 argument 에 쉽게 추가할 수 있도록 하기 위해 newInstance 함수를 따로 만들어 추가해주었다.
먼저 retrofit 을 사용하기 위해 인터넷 권한을 추가해준다.
<uses-permission android:name="android.permission.INTERNET"/>
유튜브 프로젝트에서는 모델 하나로 서버에서 내려오는 객체와 뷰에서 사용하는 객체를 동일시하여 VideoModel 을 사용했는데, 이번 프로젝트에서는 서버에서 내려오는 데이터 그 자체인 MusicEntity 와 뷰에서 사용하는 MusicModel 두개의 모델을 분리해주고 mapper 를 통해서 매핑해줄 것이다.
service/MusicEntity
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
data class MusicDto(
val musics: List<MusicEntity>
)
service/MusicService
interface MusicService {
@GET("/v3/9f853a2a-62b3-48f4-91d5-65eabd0b32f5")
fun listMusics(): Call<MusicDto>
}
MusicModel
data class MusicModel(
val id: Long,
val track: String,
val streamUrl: String,
val artist: String,
val coverUrl: String,
val isPlaying: Boolean = false
)
MusicModelMapper
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())
}
)
PlayerModel
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
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) {
}
})
}
}
PlayerFragment
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
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
}
}
}
}
PlayerFragment
private fun initRecyclerView(fragmentPlayerBinding: FragmentPlayerBinding) {
playListAdapter = PlayListAdapter {
// 음악 재생
playMusic(it)
}
fragmentPlayerBinding.playListRecyclerView.apply {
adapter = playListAdapter
layoutManager = LinearLayoutManager(context)
}
}
playListAdapter.submitList(model.getAdapterModels())
PlayFragment
private var player: SimpleExoPlayer? = 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()
}
}
addMediaItems 를 이용해 미디어 아이템을 추가해주었다.
서버에서 음악 정보를 받아오면 이를 통해 MusicModel 리스트를 받아 player 의 미디어 아이템으로 추가해주었다.
이때 MediaId 로 MusicModel 의 Id 를 설정해 구분 가능하게 사용할 수 있도록 해주었고, setUri 로 음악이 재생될 주소를 설정해주고 빌드하면 미디어 아이템이 생성된다. 이후 prepare 를 통해 준비해준다.
재생 / 일시정지 UI 전환
PlayFragment
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())
}
})
}
}
이후 필요한 이벤트 리스너를 재정의하여 구현해주면 된다.
onIsPlayingChagned : 플레이어가 재생/일시정지 상태가 되면 재생/일시정지 버튼의 아이콘을 알맞게 보여주도록 한다.
onMediaItemTransition : 미디어 아이템이 바뀔 때 마다 Id로 지정했던 mediaId 를 가져와 recyclerView 를 갱신(재생하고 있는 곡은 회색 화면으로), 플레이어뷰를 갱신(커버이미지, 제목, 아티스트명 갱신) 한다.
재생/일시정지, 이전/다음 재생 액션 추가
PlayFragment
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
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)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
updateSeek()
}
initPlayView 의 addListener 에 onPlaybackStateChanged 리스너를 재정의해주어 재생 중일 때 seekBar 의 잔량과 상태를 갱신해 주도록 하였다.
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)
}
}
PlayFragment
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
}
}
PlayFragment
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
override fun onStop() {
super.onStop()
player?.pause()
view?.removeCallbacks(updateSeekRunnable)
}
override fun onDestroy() {
super.onDestroy()
binding = null
player?.release()
view?.removeCallbacks(updateSeekRunnable)
}
사용자가 백그라운드로 나가거나 다른 앱을 사용할 경우 재생을 멈추고 seekBar 를 업데이트하는 콜백을 제거한다.
앱이 완전히 종료되는 경우 바인딩 해제, 플레이어 해제, 콜백을 제거해준다.