Kotlin

[android : kotlin] 코틀린 RecyclerView 클릭시 미디어 재생 하는 방법 : MediaController ,SimpleExoPlayer

코틀린 RecyclerView 클릭시 미디어 재생 하는 방법

서비스 클래스를 사용하여 음악 재생용 포그라운드서비스를 구현하였다. 기본 가이드 문서에는 전에 코드가 아닌 일부 코드 스니펫 정보만 제공되고 있어 MediaConroller를 사용하여 미디어를 재생하는 방법에 대해 잘 알기 어렵다. 많은 구글링을 통해 방법을 알게되었다. 포그라운드서비스에 올려서 Notification 알림 영역에 노래 재생 플레이어가 노출되기 시작했다. 그러나 문제가 생겼다. RecyclerView를 클릭했을때 해당 position에 있는 노래를 재생하고 싶은데, metadata가 널이라는 오류가 발생되었다. 커서를 통해 가져온 media 정보를 데이터 모델에 담고 uri정보를 가져와서 playFromUri메서드에 넘겨주었다.

    private fun startSong(position: Int){

        var dataModel = SongLib.musicList[position]
  
        MediaControllerCompat.getMediaController(this@MainActivity).transportControls.playFromUri(
            dataModel.contentUri, null
        )

        mediaController.transportControls.play()

    }

metatdata에 리사이클러뷰에서 클릭한 노래의 media정보를 설정을 해야하는데, 서비스단에서 설정을 해야 제대로 돌아가는데 한참을 삽질한 후에 알게되었다. 클릭했을 때 position값을 service 단으로 넘겨주기만 하면 되는데 제대로 된 방법으로 구현하고 싶어서 몇 일 동안 구글링을 하고, 테스트를 하였다. 샘플코드로 제공되고 있는 uamp 프로젝트를 살펴보았으나, 나에게는 너무 어렵게 느껴졌다. 개발방식이 나와 완전 반대였기 때문이다. 나는 개발할 때 초보 개발자가 보아도 알아보기 쉽게 코딩하는 편이다. 그런데 uamp 프로젝트 코드를 살펴보니 좀 당황스러웠다. mediaController를 사용도 처음 해보는 상황이고 exoPlayer라는게 있는지도 모르는 상황에 MediaSession은 또 뭐고… 아는게 하나도 없는 상태에서 uamp 프로젝트 코드를 보니 분석하기 싫은 마음이 한가득이였다. 필요한 정보만 확인하고 코드 재사용은 하지 않았다. 똑같은 화면을 구현하는데 저렇게 어렵게 코딩하는 방법이 있다는 것을 알았을뿐………

처리 방법은 다음과 같다. 리사이클러뷰를 클릭했을때 position값을 넘겨줘야한다. 그리고 난 후 mediaController.transportControls에서 play()메서드를 호출하여 미디어세션 콜백으로 넘겨주어야한다.

playOnURi 메서드를 호출해도 미디어세션 콜백에서 넘겨받지만 그 이후의 미디어세션의 metadata 업데이트를 해줘야하는데 다른 프로세스를 하나더 추가해야하는 불편함이 있어 보류하였다. startSong()메서드를 호출할때 SongLib.getCurrentPosition(position) 호출하여 현재 클릭한 위치의 positon 값을 변경하도록 처리해둔 후

import android.Manifest
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.PlaybackState
import android.os.Build
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.View
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.recyclerview.widget.LinearLayoutManager
import ddolcat.app.audioplayer.musicplayer.common.*
import kotlinx.android.synthetic.main.activity_main.*


private const val TAG = "TAG"

class MainActivity : AppCompatActivity() {
    val PERMISSION_REQUEST_STORAGE = 1002
    
    private lateinit var mediaBrowser: MediaBrowserCompat
    private lateinit var dataList: ArrayList<MusicViewModel>

    private lateinit var mAdapter: MediaItemAdapter
    private var mCurrentMetadata: MediaMetadata ? = null
    
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        dataList = ArrayList<MusicViewModel>()

        //권한 체크 및 Create MediaBrowserServiceCompat
        requestUserPermission()

        val list = SongLib(applicationContext)
        Log.e(TAG, "#################### SongLib.musicList.size :${SongLib.musicList.size}")
        dataList = SongLib.musicList

        //리사이클러뷰 생성 및 어탭터 설정 
        recyclerView1.layoutManager = LinearLayoutManager(
            this,
            LinearLayoutManager.VERTICAL,
            false
        )
        mAdapter = MediaItemAdapter(applicationContext, dataList)



        mAdapter.setMediaClickListener(object : MediaItemOnClickListener {
            override fun onItemClick(view: View, position: Int) {
                Log.e(TAG, "#################### 아이템클릭 :${position}")
                startSong(position)
            } 

        }) 
 
        recyclerView1.adapter = mAdapter 
 
    }
    
    
    private fun startSong(position: Int){

        //val dataModel= dataList[position]
        //Log.e(TAG, "dataModel.contentUri :${dataModel.contentUri.toString()}")

        //var pbState = MediaControllerCompat.getMediaController(this@MainActivity).playbackState.playbackState

        // val songMap: ArrayList<MediaMetadataCompat> = SongLib.getMediaSongMap(applicationContext)

//        if (dataModel != null) {
//
//            MediaControllerCompat.getMediaController(this@MainActivity).transportControls.playFromUri(
//                dataModel.contentUri, null
//            )
//
//        }
        //MediaControllerCompat.getMediaController(this).transportControls.playFromMediaId("1",null)
        //startService(Intent(applicationContext, RealMusicPlayerService::class.java))

//        var mediaId = SongLib.musicList[position].description.mediaId
//        //mediaController.transportControls.playFromMediaId(mediaId, null)
//
//        var uri = SongLib.musicList[position].description.mediaUri
//        Log.e(TAG, "###### uri :${uri}")


        //mediaController.transportControls.playFromUri(uri, null)

        //var dataModel = SongLib.musicList[position]

       // updateMetadata(dataModel)
        SongLib.getCurrentPosition(position)

//        var dataModel = SongLib.musicList[position]
//        MediaControllerCompat.getMediaController(this@MainActivity).transportControls.playFromUri(
//            dataModel.contentUri, null
//        )


        mediaController.transportControls.play()
    
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.session.MediaButtonReceiver
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.source.ExtractorMediaSource
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import com.google.android.exoplayer2.util.Util
import ddolcat.app.audioplayer.musicplayer.MainActivity
import ddolcat.app.audioplayer.musicplayer.R


private const val TAG = "TAG"
private const val MY_MEDIA_ROOT_ID = "root"
//private const val MY_MEDIA_ROOT_ID  = "MediaStore.Audio.Media.EXTERNAL_CONTENT_URI"
private const val MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id"

class MusicPlayerService : MediaBrowserServiceCompat() {
  
    private lateinit var exoPlayer: SimpleExoPlayer

    private lateinit var mediaSession: MediaSessionCompat
    private lateinit var stateBuilder: PlaybackStateCompat.Builder


    //private lateinit var songList: ArrayList<MediaMetadataCompat>
    private lateinit var songList: ArrayList<MusicViewModel>
    private var G_NOTIFICATION_ID = 0
    private lateinit var myNotificationManager: NotificationManager
    val CHANNEL_ID = "d_music_channel"
    val NOTI_ENABLE = 3080


    private lateinit var audioManager: AudioManager
    private lateinit var audioFocusRequest: AudioFocusRequest
    private var audioFocusRequested = false
  
    override fun onCreate() { 
        super.onCreate()

        //ArrayList<MusicViewModel>
        val list = SongLib(applicationContext)
        songList = SongLib.musicList

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

            myNotificationManager =
                applicationContext.getSystemService(NOTIFICATION_SERVICE) as NotificationManager


            @SuppressLint("WrongConstant")
            val notificationChannel = NotificationChannel(
                CHANNEL_ID,
                getString(R.string.notification_channel_name),
                NotificationManagerCompat.IMPORTANCE_DEFAULT
            )
            val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(notificationChannel)
            val audioAttributes: AudioAttributes = AudioAttributes.Builder()
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .build()

            //https://developer.android.com/guide/topics/media-apps/audio-app/mediasession-callbacks?hl=ko
            audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
                .setOnAudioFocusChangeListener(audioFocusChangeListener)
                .setAcceptsDelayedFocusGain(false)
                .setWillPauseWhenDucked(true)
                .setAudioAttributes(audioAttributes)
                .build()
        }

        audioManager = applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { //api 28 (android 9)
            G_NOTIFICATION_ID = (System.currentTimeMillis() % 10000).toInt()

            //createChannel()
            val channel = NotificationChannel(
                CHANNEL_ID,
                "노래재생할까?",
                //context.resources.getString(R.string.content_txt_43),
                NotificationManager.IMPORTANCE_LOW
            ) //.IMPORTANCE_HIGH);//IMPORTANCE_LOW);

//                잠금화면 알림 설정 (VISIBILITY)
//
//                VISIBILITY_SECRET : 잠금화면 알림을 사용하지 않음
//                VISIBILITY_PRIVATE : 잠금화면 알림은 표시되나, 내용은 표시되지 않음
//                VISIBILITY_PUBLIC : 전체 내용을 잠금화면에 표시
            //channel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE)

            (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
                channel
            )

        }

        // Build a PendingIntent that can be used to launch the UI.
//        val sessionActivityPendingIntent =
//            packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
//                PendingIntent.getActivity(this, 0, sessionIntent, 0)
//            }



        //https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice?hl=ko#kotlin
        //미디어 세션 초기화하기
        //
        //서비스가 onCreate() 라이프 사이클 콜백메소드를 받을 때 다음 단계를 수행해야 한다.
        //미디어 세션을 만들고 초기화합니다.
        //미디어 세션 콜백을 설정합니다.
        //미디어 세션 토큰을 설정합니다.
        //onGetRoot()는 서비스의 액세스를 제어하고,
        //onLoadChildren()은 클라이언트가 의 콘텐츠 계층 구조 메뉴를 빌드하고 표시할 수 있는 기능을 제공합니다.
        mediaSession = MediaSessionCompat(this, "RealMusicPlayerService")
            .apply {
                // Enable callbacks from MediaButtons and TransportControls
                setFlags(
                    MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
                            or MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS
                            or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
                )

                // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
                stateBuilder = PlaybackStateCompat.Builder()
                    .setActions(
                        PlaybackStateCompat.ACTION_PLAY
                                or PlaybackStateCompat.ACTION_STOP
                                or PlaybackStateCompat.ACTION_PAUSE
                                or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
                                or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
                                or PlaybackStateCompat.ACTION_PLAY_PAUSE  //ACTION_PAUSE
                    )
                    //.setState(PlaybackStateCompat.STATE_STOPPED,0,1)

                //Set our PLaybackState for the MediaSession
                setPlaybackState(stateBuilder.build())

                // MySessionCallback() has methods that handle callbacks from a media controller
                setCallback(MyMediaSessionCallback())

                // Set the session's token so that client activities can communicate with it.
                setSessionToken(sessionToken)

                val activityIntent: Intent = Intent(applicationContext, MainActivity::class.java)
                setSessionActivity(
                    PendingIntent.getActivity(
                        applicationContext,
                        0,
                        activityIntent,
                        0
                    )
                )

                //미디어 세션을 구성 할 때와 재생을 시작하기 전에 설명서에 따라 setActive(true)를 호출해야합니다.
                isActive = true
            }



        //MediaButtonReceiver
        val mediaButtonIntent = Intent(
            Intent.ACTION_MEDIA_BUTTON, null, applicationContext,
            MediaButtonReceiver::class.java
        )
        mediaSession.setMediaButtonReceiver(
            PendingIntent.getBroadcast(
                applicationContext,
                0,
                mediaButtonIntent,
                0
            )
        )
 

        initExoPlayer()


    }
    
    private fun initExoPlayer(){
        exoPlayer = SimpleExoPlayer.Builder(this).build().apply {
            setAudioAttributes(uAmpAudioAttributes, true)
            setHandleAudioBecomingNoisy(true)
            addListener(ExoPlayerListener())
        }
    }
    
    inner class ExoPlayerListener : Player.EventListener {

        override fun onTracksChanged(
            trackGroups: TrackGroupArray,
            trackSelections: TrackSelectionArray
        ) {
           // super.onTracksChanged(trackGroups, trackSelections)
        }

        override fun onLoadingChanged(isLoading: Boolean) {
           // super.onLoadingChanged(isLoading)
        }

        override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
            if (playWhenReady && playbackState == ExoPlayer.STATE_ENDED) {
                MyMediaSessionCallback().onSkipToNext()
            }
            //super.onPlayerStateChanged(playWhenReady, playbackState)
        }

        override fun onPlayerError(error: ExoPlaybackException) {
            //super.onPlayerError(error)
        }

        override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {
            //super.onPlaybackParametersChanged(playbackParameters)
        }

    }    
    .................................
    
    
    

미디어 세션에서 넘겨 받는 부분의 코드에서 현재 위치의 노래를 불러 온 후 메타데이터에 대한 업데이트 처리를 하여 해결하였다.

 var item = SongLib.getCurrent() 
 updateMetadata(item) 
 inner class MyMediaSessionCallback : MediaSessionCompat.Callback() {
        var currentState = PlaybackStateCompat.STATE_STOPPED
        private val currentUri: Uri? = null
 
 
        override fun onPlay() {
            Log.e(TAG, "#### call MyMediaSessionCallback() : onPlay")
            if(!exoPlayer.playWhenReady) {


                var item = SongLib.getCurrent()
                updateMetadata(item)

                prepareToPlay(item.contentUri)

                if (!audioFocusRequested) {
                    audioFocusRequested = true
                    val audioFocusResult: Int
                    audioFocusResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                        audioManager.requestAudioFocus(audioFocusRequest)
                    } else {
                        audioManager.requestAudioFocus(
                            audioFocusChangeListener,
                            AudioManager.STREAM_MUSIC,
                            AudioManager.AUDIOFOCUS_GAIN
                        )
                    }

                    if (audioFocusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) return
                }


                mediaSession.isActive = true   
                exoPlayer.playWhenReady = true
 
                registerReceiver(
                    becomingNoisyReceiver,
                    IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
                )

                mediaSession.setPlaybackState(
                    stateBuilder.setState(
                        PlaybackStateCompat.STATE_PLAYING,
                        PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1F
                    ).build()
                )
                currentState = PlaybackStateCompat.STATE_PLAYING
 
                refreshNotification(currentState)

            }
 

            super.onPlay()
        }
        
        ...........................

SongLib 클래스를 하나 만들고 companion object를 생성하였다.

package ddolcat.app.audioplayer.musicplayer.common

import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import java.io.FileNotFoundException
import java.io.InputStream


class SongLib {
    constructor(context: Context){
        musicList = getMediaSongList(context)
    }

    companion object {
        var musicList: ArrayList<MusicViewModel> = ArrayList()
        private var currentItemIndex = 90
        private val maxIndex: Int = musicList.size - 1

 
        fun getAlbumArt(context: Context, uri: Uri?): Bitmap? {
            val cr: ContentResolver = context.contentResolver
            var inputStream: InputStream? = null
            inputStream  = try {
                cr.openInputStream(uri!!)
            } catch (e: FileNotFoundException) {
                //e.printStackTrace();
                return null
            } catch (ee: java.lang.Exception) {
                return null
            }
            return if (inputStream != null) {
                BitmapFactory.decodeStream(inputStream)
            } else null
        }

        fun getCurrentPosition(position : Int) : MusicViewModel {
            currentItemIndex = position
            Log.e("", "SongLib.getCurrentPosition: $currentItemIndex")
            return getCurrent()
        }

        fun getCurrent() : MusicViewModel {
            Log.e("", "musicList.size: ${musicList.size}")
            Log.e("", "getCurrent.currentItemIndex: $currentItemIndex")
            return musicList[currentItemIndex]
        }

        fun getNext(): MusicViewModel {
            if (currentItemIndex == maxIndex) {
                currentItemIndex = 0
            } else {
                currentItemIndex++
            }
            Log.e("", "SongLib.getCurrent: $currentItemIndex")
            return getCurrent()
        }

        fun getPrevious(): MusicViewModel {
            if (currentItemIndex == 0) {
                currentItemIndex = maxIndex
            } else {
                currentItemIndex--
            }

            Log.e("", "SongLib.getPrevious: $currentItemIndex")
            return getCurrent()
        }

        fun getMediaSongList(context: Context) : ArrayList<MusicViewModel> {

            val mCursorCols = arrayOf(
                MediaStore.Audio.Media._ID,
                MediaStore.Audio.Media.ARTIST,
                MediaStore.Audio.Media.TITLE,
                MediaStore.Audio.Media.ALBUM,
                MediaStore.Audio.Media.DATA,
                MediaStore.Audio.Media.ALBUM_ID,
                MediaStore.Audio.Media.DURATION
            )
            var cur =  context.contentResolver.query(
                MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                mCursorCols,
                null,
                null,
                null
            )

            if (cur != null && cur.moveToFirst()) {
                lateinit var title: String
                lateinit var artist: String
                lateinit var album: String
    //            - MediaStore.Audio.Albums
    //                    - MediaStore.Audio.Artists
    //                    - MediaStore.Audio.Genres
    //                    - MediaStore.Audio.Playlists
    //                    - MediaStore.Audio.Media
    //
    //                    * Nested Inteface
    //                    - MediaStore.Audio.AlbumColumns      : 앨범을 나타내는 컬럼
    //                    - MediaStore.Audio.ArtistColumns        : 연주자를 나타내는 컬럼
    //                    - MediaStore.Audio.GenresColumns     : 쟝르를 나타내는 컬럼
    //                    - MediaStore.Audio.PlaylistsColumns   : 곡목록을 나타내는 컬럼
    //                    - MediaStore.Audio.AudioColumns       : 여러개의 테이블에서 나타나는 audio file을 위한 컬럼
                val titleColumn = cur.getColumnIndex(MediaStore.Audio.Media.TITLE)
                val artistColumn = cur.getColumnIndex(MediaStore.Audio.Media.ARTIST)
                val albumColumn = cur.getColumnIndex(MediaStore.Audio.Media.ALBUM)
                val idColumn = cur.getColumnIndex(MediaStore.Audio.Media._ID) //2020.07
                val duration = cur.getColumnIndex(MediaStore.Audio.Media.DURATION)

                try {
                    do {
                        title = cur.getString(titleColumn)
                        artist = cur.getString(artistColumn)
                        album = cur.getString(albumColumn)
                        val albumIds = cur.getLong(cur.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID))
                        //Log.e("TAG", "title = $title albumIds=$albumIds]    duration: $duration")
                        val audioId = cur.getLong(idColumn)

                        val sArtworkUri = Uri.parse("content://media/external/audio/albumart")
                        val sAlbumArtUri = ContentUris.withAppendedId(sArtworkUri, albumIds)
                        //android.util.Log.i("jinsu", "sAlbumArtUri = " + sAlbumArtUri + "]");
                        val externalUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
                        val contentUri = ContentUris.withAppendedId(externalUri, audioId)

 
                        //                          val mediaId: String,
                        //                           val title: String,
                        //                           val subtitle: String?,
                        //                            val artist: String?,
                        //                           val albumArtUri: String,
                        //                              val contentUri: Uri,
                        //                           val duration: String

                        val musicVo = MusicViewModel(
                            audioId,
                            title,
                            "서브타이틀",
                            artist,
                            sAlbumArtUri,
                            contentUri,
                            cur.getLong(duration)
                        )
                        musicList.add(musicVo)

                    } while (cur.moveToNext())
                } catch (ee: IllegalStateException) {
                    ee.printStackTrace()
                } catch (se: SecurityException) {
                    se.printStackTrace()
                    //songsList = java.util.ArrayList<MusicVo>()
                } catch (e: Exception) {
                    e.printStackTrace()
                } finally {
                    cur?.close()
                }
            }
            return musicList
        }

    }
}

[REFERENCE]

오디오 앱 개요

미디어 브라우저 서비스 빌드

미디어 브라우저 클라이언트 빌드

미디어 세션 콜백

미디어 세션 사용

Using the media controller test app

[연관 글 더보기]

[android : kotlin] 코틀린 Notification addAction 추가하는 방법(.apply) : Notification.Action.Builder

[android : kotlin] 코틀린 Notification setShowActionsInCompactView 사용 예제 : Media

[프로그래밍/Kotlin] – [android : kotlin] 코틀린 Notification 사용 예제

[프로그래밍/Android] – 잠금화면에 알림내용(NotificationCompat) 노출하기 (to show content in lock screen

Leave a Reply

error: Content is protected !!