이전에는 MediaLibraryService를 이용하여 간단하게 음악을 실행해보았다.
이제 음악 플레이어를 만들기 위해 ExoPlayer를 커스텀 해야 한다.
ExoPlayer는 Player라는 인터페이스를 상속 받아 구현하는 구현체이다.
그러니 사실상 Player에 기본적인 기능들은 다 들어가 있다.
그래서 똑같이 Player를 상속 받는 MediaController만으로 ExoPlayer의 기능들을 모두 사용할 수 있는 것이다.
그러니 "ExoPlayer 커스텀하기"보다는 "Player 커스텀하기"가 좀 더 맞는 것 같다.
이제 Player를 커스텀 해 보자.
내가 만들고 싶은 기능을 먼저 Interface로 작성하고, 그 다음 상속받아 구현체를 작성하는 편이다.
SOLID 원칙 중에 의존 역전 원칙을 잘 지키는 방법이기도 하다.
그러니 일단 필요한 것들을 생각 없이 작성해 보자.
나는 아래와 같은 필드들이 필요하다고 생각해서 작성하였다.
interface MediaPlayer {
val isPlayingFlow: StateFlow<Boolean> //현재 음악 플레이 중인지
val currentPositionFlow: Flow<Long> //현재 음악의 몇초를 플레이했는지
val durationFlow: StateFlow<Long> //지금 음악의 총 길이는 몇 ms인지
val currentMusicFlow: StateFlow<Music?> //현재 음악은 무엇인지
val musicCountFlow: StateFlow<Int>//총 음악 갯수는 무엇인지
val errorFlow: SharedFlow<PlayerError>//ExoPlayer 에러가 무엇인지
val currentMusicIndexFlow: StateFlow<Int>//현재 음악의 index가 무엇인지
val repeatModeFlow: StateFlow<RepeatMode>//현재 반복모드는 무엇인지
val isShuffleFlow: StateFlow<Boolean>//셔플 모드인지
fun play()//재생
fun pause()//멈춤
fun next()//다음
fun previous()//이전
fun seekTo(positionMillis: Long)//음악 재생바 넘김
fun seekMusicByIndex(index: Int)//음악을 넘김
fun addMusic(music: Music)//음악 추가
fun addMusic(index: Int, music: Music)//음악 index에 추가
fun addMusics(musics: List<Music>)//음악 여러개 추가
fun addMusics(index: Int, musics: List<Music>)// 음악 여러개 index에 추가
fun getMusic(index: Int): Music?//음악 가져오기
fun setRepeatMode(repeatMode: RepeatMode)//반복모드 설정
fun setShuffle(isShuffle: Boolean)// 셔플 모드 설정
}
어떻게 구현할 지, 이게 구현 가능할 지 모르지만 서비스에 필요한 기능들을 모아 작성하였다.
(다들 ExoPlayer로 뮤직 앱 잘만 만들고 있으니 아마 될것이다.)
고수준 모듈이 완성되었으니, 이것을 상속받아 구현하는 저수준 모듈을 만들어 보자.
Player에는 여러가지 이벤트에 대해 CallBack을 받을 수 있도록 Player.Listener를 제공해 준다.
이 곳에서 보면 여러 Callback을 제공해주는 것을 볼 수 있는데, 우리의 Interface인 MediaPlayer에 필요한 Callback만 설명해보고자 한다.
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
}
미디어 아이템이 바뀔 때 호출된다. mediaItem이라는 파라미터가 있어서 어떤 음악을 보고있는지를 가져올 수 있다.
이 곳에는 현재 음악이 무엇인지를 나태내는 currentMusicFlow, 현재 음악이 재생목록의 몇번째인지 Index를 나타내는 currentMusicIndexFlow를 구현할 수 있을 것 같다.
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
}
미디어가 재생되거나 정지되었을 때 호출된다. isPlaying으로 재생인지, 정지인지 알 수 있으니 isPlayingFlow에 emit하기 좋은 장소이다.
또한 음악 스트리밍이기 때문에 실행해보기 전까진 이 음악이 총 몇초인지 알지 못한다.(이것때문에 꽤 고생을 했다...)
그렇기에 현재 음악이 총 몇초인지 나타내는 durationFlow도 이곳에 넣기 좋을 것 같다.
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
}
Player에 Error가 났을 때 호출된다. 어떤 에러인지 알 수 있어서 errorFlow를 갱신하기 좋은 장소이다.
override fun onRepeatModeChanged(repeatMode: Int) {
super.onRepeatModeChanged(repeatMode)
}
반복 모드가 바뀔 때 호출된다. 이름에서 알 수 있듯이 repeatModeFlow를 호출하기에 적합한 곳이다.
repeatMode가 Int로 반환되는데, 적절히 Enum으로 변환해주자.
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled)
}
셔플 모드가 바뀔 때 호출된다. isShuffleFlow를 갱신하기 좋은 장소이다.
나는 위의 Callback만 필요하기 때문에 다른 것들은 사용하지 않았지만, Player.Listener의 안에는 이외에도 굉장히 많은 Callback이 내부에 준비되어 있기 때문에 입맛대로 골라서 사용하면 될 것 같다.
필요한 Callback을 모두 알았으나, 아직 currentPositoinFlow를 구현하지 않는다.
아쉽게도 현재 재생 시간이 바뀔 때 호출되는 Callback은 없었다.
대체 이유가 무엇일까 생각했는데, ExoPlayer Github에서 비슷한 이슈를 찾을 수 있었다.
위의 링크로 들어가 보면 성능상의 문제가 있어서 제공하지 않는다는 것을 알 수 있다.
그리고 검색을 해 보면 보통 Handler를 사용하는 경우가 많은 것 같다.
우린 Flow를 사용중인데,(물론 변환할 수 있지만) 아쉽지 않은가...!!
그래서 다른 코드를 참고하지 않고 직접 만들어 보기로 하였다.
성능 이슈가 있다는 것을 고려하여 재생 시간을 가져오는 조건은 아래와 같았다.
currentPosition을 가져와야 한다.delay 를 통해 주기를 완화시키고, Coroutine, While문을 적절히 취소시켜야 한다.이미 만들 수 있는 currentMusicFlow와 isPlayingFlow를 이용한다면 쉽게 조건을 만족시킬 수 있다.
currentMusicFlowisPlayingFlow그럼 이 두개를 combine하여 조건을 만족할 때만 Flow를 흘려주면 된다!
@OptIn(ExperimentalCoroutinesApi::class)
override val currentPositionFlow = currentMusicFlow
.combine(isPlayingFlow) { currentMusic, isPlaying ->
channelFlow {
while (isPlaying && coroutineContext.isActive) {
delay(250L)
val position = withContext(Dispatchers.Main) { player.currentPosition }
send(position)
}
}
}.flattenMerge().flowOn(Dispatchers.IO) //빠르게 변경되면 이전 flow가 취소되지 않은 상태에서 두 flow가 병렬로 실행될 수 있음.
//그래서 flattenMerge로 병합함.
위의 코드에서 flowOn(Dispatchers.IO)를 했는데, 내부에서는 withContext(Dispatchers.Main)으로 player의 값을 가져왔다.
만약 Main이 아닌 다른 Thread에서 ExoPlayer를 호출한다면 아래와 같은 Exception이 뜬다.

이제 완성된 코드는 아래와 같다!
class MediaPlayerImpl @Inject constructor(
private val player: Player
) : MediaPlayer {
override val isPlayingFlow = MutableStateFlow(false)
override val currentMusicFlow = MutableStateFlow<Music?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
override val currentPositionFlow = currentMusicFlow
.combine(isPlayingFlow) { currentMusic, isPlaying ->
channelFlow {
while (isPlaying && coroutineContext.isActive) {
delay(250L)
val position = withContext(Dispatchers.Main) { player.currentPosition }
send(position)
}
}
}.flattenMerge().flowOn(Dispatchers.IO) //빠르게 변경되면 이전 flow가 취소되지 않은 상태에서 두 flow가 병렬로 실행될 수 있음.
//그래서 flattenMerge로 병합함.
override val durationFlow = MutableStateFlow(0L)
override val errorFlow = MutableSharedFlow<PlayerError>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val currentMusicIndexFlow = MutableStateFlow(player.currentMediaItemIndex)
override val musicCountFlow = MutableStateFlow(0)
override val repeatModeFlow = MutableStateFlow(RepeatMode.NONE)
override val isShuffleFlow = MutableStateFlow(false)
init {
player.addListener(object : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
currentMusicFlow.value = mediaItem?.toMusic()
currentMusicIndexFlow.value = player.currentMediaItemIndex
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
isPlayingFlow.value = isPlaying
durationFlow.value = player.duration
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
errorFlow.tryEmit(error.toPlayerError())
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
}
override fun onRepeatModeChanged(repeatMode: Int) {
super.onRepeatModeChanged(repeatMode)
repeatModeFlow.value = getRepeatModeFromMedia(repeatMode)
}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled)
isShuffleFlow.value = shuffleModeEnabled
}
})
}
override fun play() {
if (player.playbackState == Player.STATE_IDLE)
player.prepare()
player.play()
}
override fun pause() {
player.pause()
}
override fun next() {
if (player.currentMediaItemIndex == player.mediaItemCount - 1) {
player.seekTo(0, 0L)
return
}
player.seekToNext()
}
override fun previous() {
if (player.currentMediaItemIndex == 0) {
player.seekTo(player.mediaItemCount - 1, 0L)
return
}
player.seekToPrevious()
}
override fun seekTo(positionMillis: Long) {
player.seekTo(positionMillis)
}
override fun seekMusicByIndex(index: Int) {
player.seekTo(index, 0L)
}
override fun addMusic(music: Music) {
player.addMediaItem(music.toMediaItem())
updateMusicCount()
}
override fun addMusic(index: Int, music: Music) {
player.addMediaItem(index, music.toMediaItem())
updateMusicCount()
}
override fun addMusics(musics: List<Music>) {
player.addMediaItems(musics.map { it.toMediaItem() })
updateMusicCount()
}
override fun addMusics(index: Int, musics: List<Music>) {
player.addMediaItems(index, musics.map { it.toMediaItem() })
updateMusicCount()
}
override fun getMusic(index: Int): Music? {
if (player.mediaItemCount <= index) return null
return player.getMediaItemAt(index).toMusic()
}
override fun setRepeatMode(repeatMode: RepeatMode) {
player.repeatMode = repeatMode.toMediaRepeatModeInt()
}
override fun setShuffle(isShuffle: Boolean) {
player.shuffleModeEnabled = isShuffle
}
private fun updateMusicCount() {
musicCountFlow.value = player.mediaItemCount
}
private fun getRepeatModeFromMedia(repeatModeInt: Int) = when (repeatModeInt) {
Player.REPEAT_MODE_ALL -> RepeatMode.ALL
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
Player.REPEAT_MODE_OFF -> RepeatMode.NONE
else -> {
RepeatMode.NONE
}
}
}
많이 길어졌지만, 아주 좋다..!!
이번에는 ExoPlayer를 Jetpack Compose에서 쉽게 사용하기 위해 Flow로 변환하는 과정을 거쳤다.
ExoPlayer 내부도 뜯어 보고, 특히 실시간으로 재생 시간 데이터 받아오기에 많은 고민을 쏟은 시간이었다.
다음 포스팅에는 기존 구조를 Multi Module에 맞는 구조로 바꾸어 좀 더 쉽게 사용하도록 할 예정이다.
많은 기대 부탁!