[Android Studio] 음악 스트리밍 앱

jeunguri·2022년 5월 26일
0

토이 프로젝트

목록 보기
5/8
post-thumbnail


앱 소개


완성 화면


주요 기능

  • Retrofit 을 이용해 서버에서 음악 받아와 재생 목록 구성
  • 재생 목록을 클릭하여 ExoPlayer 를 이용해 음악을 재생
  • 이전/다음 곡 재생, UI 업데이트
  • 재생 목록 화면과 플레이 화면 간 전환
  • seekBar 를 커스텀 하여 원하는 UI 로 표시

사용 기술

  • ExoPlayer
  • Retrofit
  • androidx.contraintLayout.widget.Group



의존성 추가


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와 PlayerFragment 구성


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()
        }
    }
}

Fragment 를 상속한 PlayerFragment 파일을 생성하고 메인 액티비티의 fragmentContainer 에 할당해주어 PlayerFragment 프래그먼트가 최초 실행 시 보이도록 설정해주었다.

메인 액티비티에서 값을 하나 만들고 newInstance 로 인자를 넘겨주고 apply 함수를 통해 argument 에 쉽게 추가할 수 있도록 하기 위해 newInstance 함수를 따로 만들어 추가해주었다.



음악 목록 api 서버에서 받아오기


먼저 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())
            }
    )

MusicEntity 에서 MusicModel 로 바꾸기 위해 MusicDto를 확장시켜 mapper 를 이용해 바로 모델로 바꾸어 사용할 수 있게 한다.

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 가 사용하는 PlayerModel 클래스를 생성해 모델 클래스에 데이터 관리 위임을 해주도록 하였다.

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
            }
        }
    }
}

ListAdapter 를 상속해 어댑터를 구현해주었고, 생성자로 콜백함수를 받아 아이템 클릭시 콜백을 실행하도록 구현해주었다.

PlayerFragment

private fun initRecyclerView(fragmentPlayerBinding: FragmentPlayerBinding) {
        playListAdapter = PlayListAdapter {
            // 음악 재생
            playMusic(it)
        }

        fragmentPlayerBinding.playListRecyclerView.apply {
            adapter = playListAdapter
            layoutManager = LinearLayoutManager(context)
        }
    }
playListAdapter.submitList(model.getAdapterModels())

어댑터를 PlayerFragment의 레트로핏 부분에 붙여주면 플레이리스트가 뜨게 된다.


ExoPlayer 사용하기


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()
        }
    }

변수를 정의하고 null 을 할당해주고, 빌드 시 context 가 필요하기 때문에 context 를 null 체크 해주고 빌드해준다.

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())
                }
            })
        }
    }

player 는 addListener 를 통해서 Player.EventListener 를 구현하는 익명 객체를 넣어주면 쉽게 구현할 수 있다.

이후 필요한 이벤트 리스너를 재정의하여 구현해주면 된다.

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)
        }
    }

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

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

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 를 업데이트하는 콜백을 제거한다.

앱이 완전히 종료되는 경우 바인딩 해제, 플레이어 해제, 콜백을 제거해준다.





0개의 댓글