Building an audio app - Building a media browser service Android


Building a media browser service

https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice

앱은 반드시 매니페스트내에 인텐트 필터로 MediaBrowserService를 지정해야만 한다. 자체적인 서비스 이름을 지정하며 다음 예시는 MediaPlaybackService이다.

<service android:name=".MediaPlaybackService">
  <intent-filter>
    <action android:name="android.media.browse.MediaBrowserService"/>
  </intent-filter>
</service>

일러두기: MediaBrowserService 의 추천되는 구현은 media-compat support library 의 MediaBrowserServiceCompat 이다. 이 페이지의 용어 "MediaBrowserService" 는 MediaBrowserServiceCompat 의 인스턴스를 나타낸다.

미디어 세션 초기화하기

서비스가 onCreate() 라이프 사이클 콜백메소드를 받을 때 다음 단계를 수행해야 한다.
- 미디어 세션을 생성하고 초기화한다.
- 미디어 세션 콜백을 설정한다
- 미디어 세션 토큰을 설정한다.

아래의 onCreate() 코드는 이들 단계를 보여준다.

private const val MY_MEDIA_ROOT_ID = "media_root_id"
private const val MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id"

class MediaPlaybackService : MediaBrowserServiceCompat() {
  private var mMediaSession: MediaSessionCompat?=null
  private lateinit var mStateBuilder : PlaybackStateCompat.Builder
  
  override fun onCreate() {
     super.onCreate()
    mMediaSession = MediaSessionCompat(baseContext, LOG_TAG).apply {
      // Enable callbacks from MediaButtons and TransportControls
      setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
      // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
      mStateBuilder = PlaybackStateCompat.Builder()
        .setActions(PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY_PAUSE)
      setPlaybackState(mStateBuilder.build())
      // MySessionCallback() has methods that handle callbacks from a media controller
      setCallback(MySessionCallback())
      // Set the session's token so that client activities can communicate with it.
      setSessionToken(sessionToken)
    }
  }
}

클라이언트 연결 관리하기
 
MediaBrowserServie 는 클라이언크 연결을 관리하기 위해 두 개의 메소드를 갖는다.: 서비스로의 접근을 제어하기 위한 onGetRoot() , MediaBrowserService의 컨텐트 계층의 메뉴를 빌드하고 보여주기 위한 기능을 제공하는 onLoadChildren()

onGetRoot() 로 클라이언트 접속 제어하기

onGetRoot()메소드는 컨텐트 계층의 루트 노드를 반환한다. 만약 메소드가 널을 반환하면 연결은 거부된다.

서비스에 연결할 클라이언트를 허용하고 미디어 컨텐트를 탐색하려면 onGetRoot() 는 반드시 컨텐트 계층을 나타내는 루트아이디를 가진 널이 아닌 BrowserRoot 를 반환해야 한다.

브라우징과 상관없이 MediaSession 에 접속하도록 클라이언트에게 허용하려면 onGetRoot() 는 반드시 널이 아닌 BrowserRoot 를 반환해야 하며 루트 아이디가 빈 컨텐트 계층을 가리키도록 한다.

전형적인 onGetRoot() 구현은 다음과 같다.

override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): MediaBrowserServiceCompat.BrowserRoot {
  // (선택적) 지정된 패키지 이름을 위한 레벨을 제어한다.
  // 이를 위해서는 자체적인 로직을 작성해야 한다.

  return if (allowBrowsing(clientPackageName, clientUid)) {
    // 클라이언트가 onLoadChildren() 으로 컨텐트 계층을 얻을 수 있게 하기 위한 루트 아이디를 반환한다.
    MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null)
  } else {
    // 클라이언트는 접속할 수 있지만 브라우져 루트가 빈 계층으로 onLoadChildren 이 나씽을 반환한다. 이는 컨텐트 탐색을 비활성화하는 방법이다.
  }
}

몇몇 상황에서 연결을 제어하기 위해 화이트/블랙리스트 스키마를 구현하기를 원할 수 있다. 화이트리스팅을 위해서는 see the PackageValidator class in the Universal Android Music Player sample app.

일러두기: 클라이언트 형식에 따라 다른 컨텐트 계층을 제공함을 고려하는 것이 좋다. 특히, 안드로이드 오토는 오디오앱과의 사용자 상호작용에 제한한다. 더 자세한 사항은 see Playing Audio for Auto. 연결시 clientPackageName 을 확인해 클라이언트 형식을 감지하고 클라이언트에 따라 BrowserRoot 를 반환한다.

onLoadChildren() 으로 컨텐트와 교신하기

클라이언트 접속 후 UI표시를 만들기 위해 MediaBrowserCompat.subscribe() 를 반복적으로 호출하여 컨텐츠 계층을 트라버스 할 수 있다. subscribe() 메소드는 서비스로 onLoadChildren() 콜백을 반환하여 MediaBrowser.MediaItem 객체의 리스트를 반환한다.

각 미디어 아이템은 고유 아이디 문자열을 가지는데 이 것이 토큰이다. 클라이언트가 서브메뉴나 아이템을 재생하기를 원하면 이 아이디를 건낸다. 서비스는 연결된 메뉴노드나 컨텐트 아이템과 아이디간의 연계를 수행한다.

onLoadChildren() 의 단순 구현은 다음과 같다.

override fun onLoadChildren(
  parentMediaId: String,
  result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>) {
  // Browsing not allowed
  if (MY_EMPTY_MEDIA_ROOT_ID == parentMediaId) {
    result.sendResult(null)
    return
  }

  // Assume for example that the music catalog is already loaded/cached
  val mediaItems = emptyList<MediaBrowserCompat.MediaItem>()
  // Check if this is the root menu:
  if (MY_MEDIA_ROOT_ID == parentMediaId) {
    // Build the MediaItem objects for the top level,
    // and put them in the mediaItems list..
  } else {
    // Examine the passed parentMediaId to see which submenu we're at,
    // and put the children of that menu in the mediaItems list..
  }
  result.sendResult(mediaItems)
}

일러두기: 미디어 브라우져 서비스에의해 전달될 미디어 아이템은 아이콘 비트맵을 포함하지 않아야 한다. 대신 각 아이템의 MediaDescription 을 빌드할 때 setIconUri()를 호출해 Uri를 사용한다. 

onLoadChildren() 구현 예시는 see the MediaBrowserService and Universal Android Music Player sample apps.

미디어 브라우져 서비스 라이프사이클

안드로이드 서비스의 작용은 이 것이 시작되었는지 하나 또는 그 이상의 클라이언트에 바인드되었는지에 따라 의존해 달라진다. 서비스가 생성된 이 후, 시작, 바운드, 또는 둘 다 될 수 있다. 이들 상태에있어서 완전히 기능이 작동해야 하고 설계대로 작동되어야 한다. 다른 점은 얼마나 오래 서비스가 존재하느냐 이다. 바인드된 서비스는 바인드된 클라이언트가 언바인드 하기 전까지 파괴되지 않는다. 시작된 서비스는 명시적으로 멈추거나 파괴될 수 있다. (어떤 클라이언트에도 바인드되지 않았다 가정)

다른 액티비팅에서 실행중인 미디어 브라우져가 미디어 브라우져 서비스에 접속할 때 액티비티를 서비스에 바인드하며 서비스가 바인드된 상태로 만든다. (하시만 시작은 되지 않음) 이 기본 작용은 MediaBrowserServiceCompat클래스 내에 만들어져 있다.

시작하지 않은 상태인 서비스는 모든 클라이언트가 언바인드될 때 파괴된다. 만약 UI가 이 상황에서 접속을 종료하면 서비스는 파괴된다. 이는 어떤 음악도 재생하지 않았다면 문제될 게 없다. 그러나, 재생이 시작될 때, 사용자는 아마도 앱을 스위칭한 이후에도 계속 듣고자 할 것이다. 다른 앱과 작업할 UI가 언바인드할 때도 플레이어를 파괴하기를 원치 않는다.

이런 이유로, startService() 를 호출하여 재생이 시작될 때 서비스가 시작되었는지 확인해야 한다. 시작도니 서비스는 이제 바인드되든 그렇지 않든 반드시 명시적으로 멈춰야 한다. 이를 통해 제어중인 UI 액티비티가 언바인드 되더라도 플레이어를 계속해서 수행할 수 있게 한다.

시작된 서비스를 멈추려면 Context.stopService() 또는 stopSelf() 를 호출한다. 시스템은 가급적 빨리 서비스를 멈추고 파괴해야 한다. 그러나, 만약 하나 이상의 클라이언트가 서비스에 바인드되면 모든 클라이언트가 언방인드 될 때 까지 서비스 멈춤을 지연한다. 

MediaBrowserService 의 라이프사이클은 이 생성된 방법, 바인드된 클라이언트의 갯수에 의해 제어되고  미디어 세선 콜백으로 호출된다. 요약하면,

- 서비스는 미디어 버튼이나 액티비티 바인드에 반응으로 시작될 때 생성된다. (MediaBrowser를 통해 연결된 후 )
- 미디어 세션 onPlay() 콜백은 startService() 를 호출하는 코드를 포함할 수 있다. 이는 모든 UI미디어 브라우져 액티비티가 바인드되고 언바인드될 때까지 서비스가 시작하고 실행을 지속한다.
- onStop() 콜백은 stopSelf() 를 호출해야 한다. 서비스가 시작되고 멈춘 후 만약 연결된 액티비티가 없다면 서비스는 파괴된다. 그렇지 않으면, 서비스는 모든 액티비티가 언바인드될 때까지 남아있는다. (만약 startService() 하위 호출이 서비스가 파괴되기 전에 들어오면 계류중인 스탑은 취소된다.

다음의 플로우 챠트는 서비스의 라이프사이클이 어떻게 관리되는지를 보여준다. 변수 카운터는 바인드된 클라이언트의 갯수를 트랙한다.

Service Lifecycle


포어그라운드 서비스로 미디어 스타일 노티피케이션 사용하기

서비스가 재생될 때 포어그라운드에서 실행되어야 한다. 이는 시스템이 서비스가 유용한 함수를 실행함을 알도록 해주며 시스템이 메모리 부족일 때 킬되지 않게 한다. 포어그라운드 서비스는 노티피케이션을 보여주어 사용자가 선택적으로 제어할 수 있도록한다. onPlay() 콜백은 포어그라운드에서 서비스를 넣는다.  (Note that this is a special meaning of "foreground." While Android considers the service in the foreground for purposes of process management, to the user the player is playing in the background while some other app is visible in the "foreground" on the screen.)

서비스가 포어그라운드에서 실행할 때 트랜스포트 컨트롤과 동일하게 알림을 보여준다. 노티피케이션은 세션의 메타데이터의 유용한 정보를 포함하는 것이 좋다.

플레이어가 플레이를 시작할 때 노티피케이션을 생성하고 보여준다. 이를 위한 최적의 장소는 MediaSessionCompat.Callback.onPlay() 메소드 내부이다.

아래의 예시는 NotificationCompat.MediaStyle 을 사용하는데 미디어 앱을 위해 디자인되었다. 이 것은 어떻게 노티피케이션이 메타데이터를 표시하고 컨트롤을 트랜스포트하는지 보여준다. 편의 메소드인 getController() 는 미디어 세션으로부터 직접적으로 미디어 컨트롤러를 생성할 수 있게 해준다.

// 주어진 미디어 세션과 컨텍스트 (보통 세션을 포함하는 컴포넌트)
// NotificationCompat.Builder 를 생성한다.

// 세션의 메타데이터를 얻는다.
val controller = mediaSession.controller
val mediaMetadata = controller.metadata
val description = mediaMetadata.description

val builder = NotificationCompat.Builder(context, channelId).apply {
  // 현 재생 트랙을 위한 메타데이터를 추가한다.
  setContentTitle(description.title)
  setContentText(description.subtitle)
  setSubText(description.description)
  setLargeIcon(description.iconBitmap)

  // 노티피케이션을 클릭함으로서 플레이어를 런칭할 수 있게 한다.
  setContentIntent(controll.sessionActivity)

   // 노티피케이션이 스와이프되면 서비스를 멈춘다
  setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP))

  // 락스크린에서도 트랜스포트 컨트롤이 보여지게 한다.
  setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

  // 앱 아이콘을 추가하고 강조 색상을 할당한다.
  // 색상에 대해서는 주의한다.
  setSmallIcon(R.drawable.notification_icon)
  color = ContextCompat.getColor(context, R.color.primaryDark)

  // 일시정지 버튼을 추가한다.
  addAction(NotificationCompat.Action(R.drawable.pause, getString(R.string.pause), 
      MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)))
  
  // 미디어 스타일 특성의 잇점을 얻는다.
  setStyle(android.support.v4.media.app.NotificationCompat.MediaStyle()
    .setMediaSession(mediaSession.sessionToken)
    .setShowActionsInCompactView(0)

    // 취소 버튼을 추가한다.
    .setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP))
  )
}

// 노티피케이션을 보여주고 포어그라운드에 서비스를 위치시킨다.
startForeground(id, builder.build())

미디어 스타일 노티피케이션을 사용할 떄 이들 NotificationCompat 설정의 작용에 주의해야 한다.
- setContentIntent() 를 사용할 때 노티피케이션이 클릭될 때 서비스는 자동으로 시작한다. 편리한 기능
- 락스크린과 같은 "신뢰되지 않은" 상황에서 노티피케이션의 기본 보여짐은 VISIBILITY_PRIVATE 이다. 락스크린에서도 보여지고 싶을 것이므로 VISIBILITY_PUBLIC 을 할당한다.
- 백그라운드 색상을 지정할 때는 유의한다. 안드로이드 버전 5.0 이상에서의 일반 노티는 작은 앱 아이콘의 백그라운드에만 색상이 지정된다. 그러나 안드로이드 7.0 이전의 미디어 스타일 노티피케이션은 전체 노티피케이션 백그라운드를 위해 색상이 사용된다. 백그라운드 색상을 테스트한다. Go gentle on the eyes and avoid extremely bright or fluorescent colors.

이들 설정은 NotificationCompat.MediaStyle 을 사용할 때만 설정할 수 있다
- 세션 연계를 위해 setMediaSession 을 사용한다. 이를 통해 서드파티 앱과 컴패니언 장치에서 세션에 접근하고 제어할 수 있다.
- setShowActionsInCompactView()를 통해 3개의 액션까지 노티피케이션의 표준 사이즈 컨텐트 뷰에 보여지게 한다. (여기에는 일시정지 버튼이 지정되었다.)
- 안드로이드 5.0 이상에서는 포어그라운드에서 실행중인 서비스를 스와이프 어웨이로 중지할 수 있다. 앞선 버전에서는 불가능하다. 사용자가 안드로이드 5.0 이전에서 노티피케이션을 없애고 플레이백을 중지시키기 위해 setShowCancelButton(true) 와 setCancelButtonIntent() 를 호출해 노티피케이션의 우상단 코너에 취소버튼을 추가할 수 있다.

일시정지, 취소 버튼을 추가할 때 플레이백 액션을 붙이는 PendingIntent 가 필요하다. MediaButtonReceiver.buildMediaButtonPendingIntent() 메소드는 PlaybackState 액션을 PendingIntent 로 전환하는 작업을 수행한다.



덧글

댓글 입력 영역