ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MediaPlayer:안드로이드에서 간단한 비디오 재생
    프로그래밍/Android 2020. 11. 21. 22:54
    반응형

    안드로이드 앱에서 오디오나 비디오 플레이를 하는 것은 많은 프로젝트에서 흔한 요구사항이다. 구글 스토어에 올라와있는 많은 앱들에서 심지어 로컬 비디오나 오디오에 대해서도 많이 제공한다. 

     

    MediaPlayer

    MeaiaPlayer는 안드로이드 멀티미디어 프레임워크의 한 부분으로 res 디렉토리나 갤러리로부터 오디오나 비디오를 재생하게 한다. 또한 URL로부터 오디오나 비디오 스트리밍을 가능하게 해준다. 

     

    https://www.raywenderlich.com/14273655-mediaplayer-simplified-video-playback-on-android

     

     

    The basic

    MediaPlayer : 오디오와 비디오 재생

     

    Manifest declarations

    MediaPlayer로 네트워크를 사용한 스트리밍 재생을 하려고 한다면 네크워크 퍼미션이 필요하다. 

    <uses-permission android:name="android.permission.INTERNET" />

     

    MediaPlayer로 재생중에는 스크린을 계속 잠기지 않게 하려면 아래 메소드 콜과 퍼미션이 필요하다. 

    MediaPlayer.setScreenOnWhilePlaying() 
    MediaPlayer.setWakeMode()
    
    // Permission
    <uses-permission android:name="android.permission.WAKE_LOCK" />

     

    Using MediaPlayer

    미디어 프레임워크의 중요한 컴포넌트 중에 하나는 MediaPlyaer이다. 최소한의 셋팅으로 미디어를 Fetch, decode, play할 수 있게 한다. Local resource, Content Resolver로부터 내부 URI 혹은 외부 네트워크로부터 받아오는 미디어 모두 재생가능하다. 안드로이드 미디어 포맷을 따른다. (https://developer.android.com/guide/topics/media/media-formats) 

    var mediaPlayer: MediaPlayer? = MediaPlayer.create(context, R.raw.sound_file_1)
    mediaPlayer?.start() // no need to call prepare(); create() does that for you

     

    이 경우에는 raw 폴더 리소스에서 오디오 파일을 가져오는 경우이다. 그런데 인코딩되지 않은 로우 데이터가 아니라 지원되는 포맷으로 인코딩되어야한다.

     

    시스템에서 URI를 얻어와 재생하는 경우이다. 

    val myUri: Uri = .... // initialize Uri here
    
    val mediaPlayer: MediaPlayer? = MediaPlayer().apply {
        setAudioAttributes(
            AudioAttributes.Builder()
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .build()
        )
    
        setDataSource(applicationContext, myUri)
        prepare()
        start()
    }

     

    HTTP를 통해 얻어오는 경우는 다음과 같다.

    val url = "http://........" // your URL here
    
    val mediaPlayer: MediaPlayer? = MediaPlayer().apply {
        setAudioAttributes(
            AudioAttributes.Builder()
                .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                .setUsage(AudioAttributes.USAGE_MEDIA)
                .build()
        )
        setDataSource(url)
        prepare() // might take long! (for buffering, etc)
        start()
    }

     

    두 가지 주의할 점이 있다. 

    • 스트리밍을 하려고 한다면 그 미디어 파일은 반드시 progressive 해야한다. 
    • 주의할 점은 setDataSource() URI 유효하지 않으면 IllegalArgumentException IOException 일어나게된다. 

     

    Asynchronous preparation

    MediaPlayer를 사용하는 것은 간단하다. 하지만 안드로이드 앱에서 잘 동작하게 하기 위해서는 몇가지 조건이 필요하다. 예를들면 prepare() 호출은 미디어 데이터를 네트워크로 가져오고 디코딩하는데 시간이 걸릴 수 있다. 재생에 오랜 시간이 걸릴 것을 대비해서 해당 메소드는 UI 쓰레드에서 호출하면 안 된다. 해당 메소드를 UI 쓰레드에서 호출하는 것은 리턴이 될 때까지 기다려야하는 것이므로 ANR이 일어날 수 있다. 

    ANR을 피하기 위해서는 다른 쓰레드에서 MediaPlayer를 로드해야하고 메인쓰레드에 로드를 완료했음을 알려줘야한다. 그런데 thread 로직을 직접 작성하면 상관없지만 MediaPlayer가 제공하는 prepareAsync() 메소드를 이용할 수도 있다. 이 메소드는 워커 쓰레드에서 미디어를 로드해서 UI 쓰레드 블로킹없이 충분한 데이터의 버퍼를 채워놓느다. 미디어 데이터 로드가 완료되면 onPrepared() 콜백이 호출된다. (setOnPreapredListener()에 리스너를 설정했을 때의 경우)

     

    Managing state

    MediaPlayer의 다른점은 State 기반으로 동작한다는 것이다. 즉, MediaPlayer는 내부 state가 있는데 코드 작성할 때 반드시 숙지해야한다. 왜냐하면 몇몇 메소드들은 특정 상태에서만 동작하기 때문이다. 만약 잘못된 상태에서 메소드를 호출했을 때, Exception을 던질수도 있다. 

    미디어 파일을 재생할 , MediaPlayer  가지 상태로 변경되게 되는데 아래 다이어그램을   보자.

    1. Idle State : 새로운 MediaPlayer instance 생성하면 맨처음에는 Idle 상태가 된다. 이 상태에서는 play, pause, stop을 할 수 없다. 억지로 하려고하면 앱은 crash할 것이다.

    2. End State : MediaPlayer의 release()호출은 리소스를 해제하고 End 상태로 진입하는 것을 의미한다. 마찬가지로 play, pause가 불가능하다.

    3. Error State : 초기화되지 않은 MediaPlayer 의 play, pause, stop을 시도할 때 진입하는 상태이다. 그러나 onErrorListener.onError() 콜백으로 에러를 잡을 수 있다.

    4. Initialized State : MediaPlayer는 미디어 데이터를 전달받으면 이 상태에 진입한다. setDataSource()를 이용해서 재생할 미디어를 전달한다. MediaPlayer가 idle 상태일때만 미디어를 전달할 수 있고, 그렇지 않을 때 전달하면 illegalStateException을 던진다. 

    5. Prepared State : 파일이나 URL로부터 미디어를 재생하기 전에 prepare()나 prepareAsync()를 호출해서 MediaPlayer를 준비해야한다. 준비가 되면 이 상태에 진입하고 onPreparedListener() 콜백이 호출된다.

    6. Stated State : MediaPlayer가 준비가되면 이제 start()를 호출해서 미디어를 paly할 수 있다. 재생중에는 MediaPlayer가 이 상태에 진입한다.

    7. Paused State : 미디어를 pause할 때, MediaPlayer는 이 상태에 진입한다. MediaPlayer가 pause하기 위해서는 pause()를 호출한다.

    8. MediaPlayer가 미디어 재생을 멈추면 이 상태에 진입한다. MediaPlayer가 이 상태일 때 미디어를 다시 재생하려고 하면 prepare()나 prepareAsync()를 다시 호출해서 준비해야한다. 

    9. PlaybackCompleted State : 재생이 완료됐을 때의 상태이다. 추가적으로 onCompletion() 콜백이 호출된다. MediaPlayer가 이 상태면 start()를 다시 호출할 수 있다. 

     

    다이어그램은 특정 상태에서 다른 상태로 옮겨가는 MediaPlayer 모습이다. 예를 들면 상태에서 setDataSource() 호출해야하고 Initialized 상태로 변경된다. 다음으로 prepare() prepareAsync() 호출해야하며 로드가 완료되면 Prepared 상태로 변경된다. 상태는 play 가능한 start() 호출해도 되는 상태이다. start(), pause(), seekTo() 메소드를 이용해 Start, Pause, PlaybackCompleted 상태로 변경가능하다. 주의할 점은 stop() 호출하면 start() 다시 호출할 없다. prepare() 처음부터 다시 호출해줘야한다. 

     

    Releasing the MediaPlayer

    MediaPlayer는 시스템 리소스를 잡아먹는다. 따라서 MediaPlayer 인스턴스의 사용이 끝났으면 release()를 호출해서 allocate되어있는 리소스들을 릴리즈하도록 도와줘야된다. 예를 들면 MediaPlayer를 사용하고 이를 사용 중인 activity가 onStop() 콜백에 있다면 MediaPlayer는 반드시 release()되어야한다. 왜냐하면 유저가 화면을 더이상 보고있지 않으므로 화면에 보이는 동작을 할 필요가 없기 때문이다. (BG에서 미디어를 재생할 일이 없을 때) activity가 다시 onResume이나 onRestart가 되면 MediaPlayer를 다시 prepare 시켜야한다. 

    mediaPlayer?.release()
    mediaPlayer = null

    activity가 onStop()이 호출되었는데,  MediaPlayer를 릴리즈하지 않고 activity의 재시작 타이밍에 MediaPlayer를 다시 생성했다고 가정해보자. 모두가 알다시피 별다른 설정을 하지 않았다면 화면을 회전시켰을 때 activity는 재생성된다. 이 때는 MediaPlayer가 릴리즈되지 않은 상태에서 계속 새로 만들어지므로모든 시스템 리소스를 빠르게 소모할 것이다. 

    만약에 BG에서 MediaPlayer 미디어를 계속 재생하고 싶다면 MediaPlayer + Service+ 노티피케이션바를 이용해야한다. 

     

    Using MediaPlayer in a service

    만약 BG에서 미디어를 재생하고 싶다면, 즉, 다른 앱이 동작할 때에도 계속 재생하고 싶다면, Service를 시작해서 여기에서 MediaPlayer 인스턴스를 생성하면 된다. MediaBrowserServiceCompat 서비스에서 MediaPlayer를 생성하고 다른 activity에서 MediaBrowserCompat으로 동작하면 된다. 

     

    Running asynchronously

    Activity와 같이 Service도 메인쓰레드로 동작한다. 그러므로 의도한 바를 빠르게 수행하고 불필요하게 긴시간 리소스를 잡아먹는 일이 없도록 해야한다. 만약 무거운 작업이 동작해야한다면 비동기적으로 처리하도록 호출해야한다. 

    예를 들어보자. MediaPlayer의 prepare()보다는 prepareAsync()를 호출해서 MediaPlayer.OnPreparedListener 를 구현해 완료 알림을 받도록 한다. 

    private const val ACTION_PLAY: String = "com.example.action.PLAY"
    
    class MyService: Service(), MediaPlayer.OnPreparedListener {
    
        private var mMediaPlayer: MediaPlayer? = null
    
        override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
            ...
            val action: String = intent.action
            when(action) {
                ACTION_PLAY -> {
                    mMediaPlayer = ... // initialize it here
                    mMediaPlayer?.apply {
                        setOnPreparedListener(this@MyService)
                        prepareAsync() // prepare async to not block main thread
                    }
    
                }
            }
            ...
        }
    
        /** Called when MediaPlayer is ready */
        override fun onPrepared(mediaPlayer: MediaPlayer) {
            mediaPlayer.start()
        }
    }

     

    Handling asynchronous errors

    MediaPlayer 비동기적으로 사용하고 있다면 MediaPlyaer.OnErrorListener 등록하고 콜백을 받도록 한다.

    class MyService : Service(), MediaPlayer.OnErrorListener {
    
        private var mediaPlayer: MediaPlayer? = null
    
        fun initMediaPlayer() {
            // ...initialize the MediaPlayer here...
            mediaPlayer?.setOnErrorListener(this)
        }
    
        override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
            // ... react appropriately ...
            // The MediaPlayer has moved to the Error state, must be reset!
        }
    }
    

    중요한 것은 에러가 발생했을 때 MediaPlayer는 에러 상태로 진입하기 때문에 reset을 반드시 하고 사용해야한다. 

     

    Using wake locks

    미디어를 백그라운드에서 사용할 때, Service에서 도는 MediaPlayer는 디바이스가 슬립 모드에 빠질 수있다. 이 슬립 모드는 와이파이나 CPU을 포함한 리소스가 필요하지 않다고 판단해서 시스템이 중단을 할 수 있다. 음악 재생 등을 해야할 때는 이 슬립 모드에 빠지지 않도록 시스템에 요청해야한다. 

    Service 에서 동작하는 MediaPlayer를 계속 이용하기 위해서는 “wake lock”을 사용해야한다. 이 “wake lock”은 시스템에 슬립모드에 빠지지 않도록 요청하는 것이다.

    주의할점은 이 wake lock으로 슬립모드를 사용하지 않으면 배터리 사용을 증가시키기 때문에 정말 필요할 때만 사용해야한다. 

     

    MediaPlayer 최초에 만들 setWakeMode() 메소드를 호출해서 wake lock 사용할 있다. `PowerManager.PARTIAL_WAKE_LOCK` MediaPlayer pause, stop 상태에서만 일시적인 락을 사용한다. 

    mediaPlayer = MediaPlayer().apply {
        // ... other initialization here ...
        setWakeMode(applicationContext, PowerManager.PARTIAL_WAKE_LOCK)
    }

     

    그런데 wake lock CPU 깨어있도록 보장한다. 만약 스트리밍으로 미디어를 재생한다면 와이파이로 깨어있도록 해야된다. MediaPlayer 초기화할 Wi-Fi lock 직접 만들어 사용한다. 

    val wifiManager = getSystemService(Context.WIFI_SERVICE) as WifiManager
    val wifiLock: WifiManager.WifiLock =
        wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock")
    
    wifiLock.acquire()

    미디어를 pause stop했다면 lock 릴리즈해준다. 

    wifiLock.release()
    

     

    Performing cleanup

    이전에 언급했듯이 MediaPlayer 인스턴스는 시스템 리소스를 많이 잡아먹기 때문에 더이상 사용하지 않을 release() 호출해야한다고 했다. GC 작업을 맡기는 보다 release() 명시적으로 호출하는 것이 효율적이다. 왜냐하면 GC MediaPlayer 회수하는데 약간 시간이 걸릴수도 있다. 

    class MyService : Service() {
    
        private var mediaPlayer: MediaPlayer? = null
        // ...
    
        override fun onDestroy() {
            super.onDestroy()
            mediaPlayer?.release()
        }
    }

    MediaPlayer 릴리즈해야할 때를 판단해야한다. 예를 들면 화면을 벗어낫을 때와 같이 정말 사용하지 않는 경우만 해당한다. MediaPlayer 잠깐 동안만 중지하고 다시 사용해야할 때는 새로 생성해서 준비하는 오버헤드도 크기 때문이 이때는 굳이 릴리즈를 하지 않아도 된다. 

     

    Media Controls

    "미디어 컨트롤"은 music player, podcasts, video player 등 여러개를 재생하는 유저 니즈에 맞게 사용될 목적을 가진다.  Android 11에서는 MediaSession과 MediaRouter2 API와 사용할 수 있도록 업데이트 되었다.

    미디어 컨트롤은 UI상으로 Android 11에는 노티바의 퀵 셋팅으로 변경되었다. 스와이프가 가능한 캐러셀 뷰로 표시되고 아래 순서로 리스트된다. 

    • 휴대전화에서 재생되는 스트림
    • 외부 기기 또는 Cast 세션에서 감지되는 원격 스트림
    • 재개 가능한 세션 

    유저는 이 미디어 컨트롤을 이용해 이전 세션을 다시 시작할 수 있다. 

     

    Android 10이전에는 미디어 포함 대부분의 알람이 노티피케이션 공간에 표시되어있었다. 하지만 Android 11부터 퀵 셋팅 공간에 위치하도록 변경되면서 유저가 더 편하게 미디어를 관리, 사용하도록 변경되었다.

    여러 뮤직앱 재생 시 정리되지 않음 (Android 10)

     

    Displaying media controls for your app

    별다른일을 하지 않고도 MediaStyle MediaSession class 이용해서 미디어 컨트롤을 표시할 있다. 작은  아이콘과 이름을 표시할  있고 아래쪽에는 액션들을 표시할  있다.

    // Create a media session. NotificationCompat.MediaStyle
    // PlayerService is your own Service or Activity responsible for media playback.  
    val mediaSession = MediaSessionCompat(this, "PlayerService")
    
    // Create a MediaStyle object and supply your media session token to it. 
    val mediaStyle = Notification.MediaStyle().setMediaSession(mediaSession.sessionToken)
    
    // Create a Notification which is styled by your MediaStyle object. 
    // This connects your media session to the media controls. 
    // Don't forget to include a small icon.
    val notification = Notification.Builder(this@PlayerService, CHANNEL_ID)
                .setStyle(mediaStyle)
                .setSmallIcon(R.drawable.ic_app_logo)
                .build()
    
    // Specify any actions which your users can perform, such as pausing and skipping to the next track. 
    val pauseAction: Notification.Action = Notification.Action.Builder(
                pauseIcon, "Pause", pauseIntent
            ).build()
    notification.addAction(pauseAction)
    

     

    ExoPlayer's Notification 과 action 선언이다.

    val notificationManager = PlayerNotificationManager.createWithNotificationChannel(
                  context,
                  channelId,
                  channelName,
                  channelDescription,
                  notificationId,
                  mediaDescriptionAdapter,
                  notificationListener)
                  
                  
    private class NotificationBroadcastReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
          Player player = PlayerNotificationManager.this.player;
          if (player == null
              || !isNotificationStarted
              || intent.getIntExtra(EXTRA_INSTANCE_ID, instanceId) != instanceId) {
            return;
          }
          String action = intent.getAction();
          if (ACTION_PLAY.equals(action)) {
            if (player.getPlaybackState() == Player.STATE_IDLE) {
              if (playbackPreparer != null) {
                playbackPreparer.preparePlayback();
              }
            } else if (player.getPlaybackState() == Player.STATE_ENDED) {
              seekTo(player, player.getCurrentWindowIndex(), C.TIME_UNSET);
            }
            controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ true);
          } else if (ACTION_PAUSE.equals(action)) {
            controlDispatcher.dispatchSetPlayWhenReady(player, /* playWhenReady= */ false);
          } else if (ACTION_PREVIOUS.equals(action)) {
            previous(player);
          } else if (ACTION_REWIND.equals(action)) {
            rewind(player);
          } else if (ACTION_FAST_FORWARD.equals(action)) {
            fastForward(player);
          } else if (ACTION_NEXT.equals(action)) {
            next(player);
          } else if (ACTION_STOP.equals(action)) {
            controlDispatcher.dispatchStop(player, /* reset= */ true);
          } else if (ACTION_DISMISS.equals(action)) {
            stopNotification(/* dismissedByUser= */ true);
          } else if (action != null
              && customActionReceiver != null
              && customActions.containsKey(action)) {
            customActionReceiver.onCustomAction(player, action, intent);
          }
        }
      }

     

    남아있는 UI 필드들, 트랙 제목이나 재생 위치 등은 media session 이용해서 표시할 있다. 

    mediaSession.setMetadata(
        MediaMetadataCompat.Builder()
            
            // Title. 
            .putString(MediaMetadata.METADATA_KEY_TITLE, currentTrack.title)
    
            // Artist. 
            // Could also be the channel name or TV series.
            .putString(MediaMetadata.METADATA_KEY_ARTIST, currentTrack.artist)
            
            // Album art. 
            // Could also be a screenshot or hero image for video content
            // The URI scheme needs to be "content", "file", or "android.resource".
            .putString(
                MediaMetadata.METADATA_KEY_ALBUM_ART_URI, currentTrack.albumArtUri)
            )
    
            // Duration. 
            // If duration isn't set, such as for live broadcasts, then the progress
            // indicator won't be shown on the seekbar.
            .putLong(MediaMetadata.METADATA_KEY_DURATION, currentTrack.duration) // 4
    
            .build()
    )

    media session을 통해 Seekbar 업데이트도 가능하다.

    mediaSession.setPlaybackState(
        PlaybackStateCompat.Builder()
            .setState(
                PlaybackStateCompat.STATE_PLAYING,
                            
                // Playback position.
                // Used to update the elapsed time and the progress bar. 
                mediaPlayer.currentPosition.toLong(), 
                            
                // Playback speed. 
                // Determines the rate at which the elapsed time changes. 
                playbackSpeed
            )
    
            // isSeekable. 
            // Adding the SEEK_TO action indicates that seeking is supported 
            // and makes the seekbar position marker draggable. If this is not 
            // supplied seek will be disabled but progress will still be shown.
            .setActions(PlaybackStateCompat.ACTION_SEEK_TO)
            .build()
    )

     

    Media resumption

    동영상을 보던 유저가 앱을 떠났다. 유저가 봤던 부분부터 다시 재생하려면?

    두 가지 단계가 필요하다. 최근 미디어앱을 찾고 다시 재생을 시작하는 것이다.

     

    1. Discovering recent media apps

    안드로이드는 먼저 최근 미디어앱을 찾고 가장 최근 재생한 콘텐츠가 무엇인지 묻는다. 그런 다음 그 콘텐츠에 대한 미디어 컨트롤을 만든다. 

    안드로이드가 앱을 발견하게 하기 위해서는 MediaBrowserService를 이용해야하는데 일반적으로는 안드로이드 Jetpack에 MediaBrowserServiceCompat을 이용한다. 

    안드로이드는 앱에 구현되어 있는 MediaBrowserServiceCompat의 onGetRoot 메소드를 호출하는데, 리턴값은 현재 미디어에 대한 Root를 리턴한다. 방법은 EXTRA_RECENT를 이용해야한다. 

    EXTRA_RECENT는 특수한 경우에 처리하고 미디어 트리를 전달할 때 최근 재생된 미디어 아이템을 첫 번째 인자로 넣어서 전달해야한다.

    안드로이드가 onLoadChildren 메소드를 호출하는 것은 이 미디어 트리를 얻기 위함이다. MediaItem 의 리스트로 전달한다.

    즉, 

    • MediaItem 리스트 
    * root
     *  +-- Albums
     *  |    +-- Album_A
     *  |    |    +-- Song_1
     *  |    |    +-- Song_2
     *  ...
     *  +-- Artists
     *  ...
    • MediaBrowserService 최초 생성시 onGetRoot() 호출되어 parentMediaId 등록한다.

    • 노티피케이션으로 진입 혹은 앱 안에서 다른 parentMediaId로 진입(ex. Album_A) 시 onLoadChildren() 호출되어 재생가능한 MediaItem 리스트 반환해야한다.

    fun onGetRoot(
            clientPackageName: String,
            clientUid: Int,
            rootHints: Bundle?
    ): BrowserRoot? 
    
    fun onLoadChildren(
            parentMediaId: String,
            result: Result<List<MediaItem>>
    )


    2. Resuming playback

    만약 유저가 재생 버튼을 누르면 시스템은 EXTRA_RECENT 힌트로 onGetRoot() 호출할 것이다

    안드로이드는 미디어 세션에 연결하고 play 커맨드를 미디어세션에 요청한다. Media session의 onPlay 콜백을 상속해서 미디어 컨텐츠의 시작을 알 수 있고 이때 MediaStyle 노티피케이션을 만들면 된다. 

    노티피케이션을 다시 생성하면 static media control 일반 미디어 컨트롤로 다시 변경된다. 

     

     

    https://developer.android.com/guide/topics/media/mediaplaye

     

    MediaPlayer 개요  |  Android 개발자  |  Android Developers

    Android 멀티미디어 프레임워크는 다양한 일반 미디어 유형의 재생을 지원하므로 오디오, 동영상, 이미지를 애플리케이션에 쉽게 통합할 수 있습니다. 애플리케이션 리소스(원시 리소스)에 저장

    developer.android.com

    android-developers.googleblog.com/2020/08/playing-nicely-with-media-controls.html

     

     

     

    반응형

    '프로그래밍 > Android' 카테고리의 다른 글

    Large screen  (0) 2021.08.31
    Android Room 데이터베이스 코드랩  (0) 2021.03.30
    Google i/o 2018 - ExoPlayer 2.8  (0) 2018.06.06
    Property View  (0) 2017.12.20
    Introduction to Physics-based animations in Android  (0) 2017.09.06
Designed by Tistory.