프로그래머스 과제테스트를 진행하다가 음악 앱에 대한 지식이 전반적으로 부족한 것 같아서
다음 Android 개발자 공식문서 - 오디오 앱 빌드 를 참고하며 공부하였다.
물론 공식문서는 무조건 영어다! hl=en으로 바꿔주자
가장 권장되는 음악 앱의 아키텍처는 위에 첨부한 사진처럼 클라이언트 - 서버 디자인이라고 한다.
여기서 클라이언트는 MediaBrowser, MediaController, UI를 포함하고 있는 Activity 라고 할 수 있고, 서버는 Player와 MediaSession을 포함하고 있는 MediaBrowserService라고 할 수 있다.
먼저 manifest의 intent-filter 안에 MediaBrowserService를 선언해야 한다.
<service android:name=".MediaPlaybackService">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
그 다음, media session을 초기화해야한다.
초기화 하는 방법은 다음과 같다.
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 mediaSession: MediaSessionCompat? = null
private lateinit var stateBuilder: PlaybackStateCompat.Builder
override fun onCreate() {
super.onCreate()
// Create a MediaSessionCompat
mediaSession = 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
stateBuilder = PlaybackStateCompat.Builder()
.setActions(PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PLAY_PAUSE
)
setPlaybackState(stateBuilder.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)
}
}
}
여기서 playbackstate는 말 그대로 현재 음악의 재생 상태(재생, 정지 등)를 의미한다.
이렇게 미디어 세션을 초기화하고 나서는 클라이언트 연결을 처리하는 로직을 작성해야 한다.
MediaBrowserServiceCompat 구현 소스코드를 자세히 뜯어다 보면 다음과 같이 onGetRoot()와 onLoadChildren() 추상 메서드를 확인할 수 있다.
이 두가지 메서드를 통해 어떻게 클라이언트 연결을 처리할지 살펴보자.
쉽게 말해 onGetRoot() 메서드는 콘텐츠 계층 구조의 루트 노드를 반환한다.
메서드가 null을 반환하면 연결이 거부된다.
만약 클라이언트가 서비스에 연결하고 미디어 콘텐츠를 탐색할 수 있도록 하려면 onGetRoot()는 콘텐츠 계층 구조를 나타내는 루트 ID인 BrowserRoot를 반환해야 한다. (null이 아님!)
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): MediaBrowserServiceCompat.BrowserRoot {
// (Optional) Control the level of access for the specified package name.
// You'll need to write your own logic to do this.
return if (allowBrowsing(clientPackageName, clientUid)) {
// Returns a root ID that clients can use with onLoadChildren() to retrieve
// the content hierarchy.
MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null)
} else {
// Clients can connect, but this BrowserRoot is an empty hierachy
// so onLoadChildren returns nothing. This disables the ability to browse for content.
MediaBrowserServiceCompat.BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null)
}
}
주석을 잘 읽어보면 무조건 BrowserRoot를 반환해야 함을 알 수 있다.
클라이언트가 연결된 후, MediaBrowserCompat.subscribe()의 반복 호출로 콘텐츠 계층 구조를 순회할 수 있다.
subscribe() 메서드는 onLoadChildren() 콜백을 서비스로 전송한다.
그러면 MediaBrowser.MediaItem 객체 리스트가 반환된다.
그리고, 각각의 MediaItem은 고유의 ID 문자열을 가지는데, 이것은 opaque token이다.
service는 이 ID값을 적절한 메뉴 노드 또는 콘텐츠 항목과 연결하는 역할을 한다.
다음 예제를 살펴보자.
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)
}
재생 중인 서비스는 포그라운드에서 실행되어야 한다.
그러면 시스템은 서비스가 유용한 기능을 실행 중임을 알게 되고 시스템 메모리가 부족해도 서비스를 종료하지 않는다.
또한, 포그라운드 서비스는 사용자가 인지하고 선택적으로 제어할 수 있도록 알림을 표시해야 한다.
NotificationCompat.MediaStyle을 사용하여 미디어 앱으로 설계한 다음 예시를 보자.
// Given a media session and its context (usually the component containing the session)
// Create a NotificationCompat.Builder
// Get the session's metadata
val controller = mediaSession.controller
val mediaMetadata = controller.metadata
val description = mediaMetadata.description
val builder = NotificationCompat.Builder(context, channelId).apply {
// Add the metadata for the currently playing track
setContentTitle(description.title)
setContentText(description.subtitle)
setSubText(description.description)
setLargeIcon(description.iconBitmap)
// Enable launching the player by clicking the notification
setContentIntent(controller.sessionActivity)
// Stop the service when the notification is swiped away
setDeleteIntent(
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_STOP
)
)
// Make the transport controls visible on the lockscreen
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
// Add an app icon and set its accent color
// Be careful about the color
setSmallIcon(R.drawable.notification_icon)
color = ContextCompat.getColor(context, R.color.primaryDark)
// Add a pause button
addAction(
NotificationCompat.Action(
R.drawable.pause,
getString(R.string.pause),
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_PLAY_PAUSE
)
)
)
// Take advantage of MediaStyle features
setStyle(android.support.v4.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0)
// Add a cancel button
.setShowCancelButton(true)
.setCancelButtonIntent(
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_STOP
)
)
)
}
// Display the notification and place the service in the foreground
startForeground(id, builder.build())
🐥 프로젝트를 하면서 알게 된 사실!
description.subtitle에 보통 해당 노래의 가수 혹은 아티스트 값을 할당한다.
setLargeIcon은 앨범 커버 이미지를 설정할 수 있다. (필수는 아님!)
위 예제 코드를 보면 NotificationCompat.Builder에 설정해야 하는 값들이 되게 많음을 알 수 있다.
글 젤 처음에 첨부했던 클라이언트 - 서버 디자인 아키텍처 사진에서처럼 앱을 완성시키려면 Activity 컴포넌트에서 UI코드, MediaController, MediaBrowser 로직을 완성해야 한다.
MediaBrowser에는 중요한 두 가지 기능이 있다.
1. MediaBrowserService에 연결한다.
2. 연결 이후 UI의 MediaController를 만든다.
먼저 예제 코드부터 살펴보자.
class MediaPlayerActivity : AppCompatActivity() {
private lateinit var mediaBrowser: MediaBrowserCompat
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
// Create MediaBrowserServiceCompat
mediaBrowser = MediaBrowserCompat(
this,
ComponentName(this, MediaPlaybackService::class.java),
connectionCallbacks,
null // optional Bundle
)
}
public override fun onStart() {
super.onStart()
mediaBrowser.connect()
}
public override fun onResume() {
super.onResume()
volumeControlStream = AudioManager.STREAM_MUSIC
}
public override fun onStop() {
super.onStop()
// (see "stay in sync with the MediaSession")
MediaControllerCompat.getMediaController(this)?.unregisterCallback(controllerCallback)
mediaBrowser.disconnect()
}
}
onCreate() 에서는 MediaBrowserCompat 객체를 만든다.
또한 여기서 connectionCallbacks를 확인할 수 있는데 이는 미리 정의한 MediaBrowserCompat.ConnectionCallback 변수라고 보면 된다.
onStart()에서는 MediaBrowserService에 연결하는 로직이 등장한다.
또한 여기서 MediaBrowserCompat.ConnectionCallback이 작동하게 되는데, 만약 연결이 성공하면 onConnect() 콜백이 미디어 컨트롤러를 만들어 미디어 세션에 연결하고 UI 컨트롤을 MediaController에 연결하며 컨트롤러를 등록하여 미디어 세션에서 콜백을 수신한다.
onResume()에서는 앱이 기기의 볼륨 컨트롤에 응답하도록 오디오 스트림을 설정한다.
onStop()에서는 Activity가 중지될 때, MediaBrowser 연결을 끊고 MediaController.Callback 을 등록 해제한다.
만약 activity에서 MediaBrowserCompat가 생성되면 반드시 ConnectionCallback 인스턴스를 생성해줘야 한다.
(바로 위 예제에서 connectionCallbacks이라고 보면 된다!)
MediaBrowserService으로부터 media session token을 추출하고 이 토큰을 사용하여 MediaControllerCompat를 만들려면 onConnected() 메서드를 수정해야 한다.
다음 예제 코드는 onConnected() 메서드를 수정하는 방법을 보여준다.
private val connectionCallbacks = object : MediaBrowserCompat.ConnectionCallback() {
override fun onConnected() {
// Get the token for the MediaSession
mediaBrowser.sessionToken.also { token ->
// Create a MediaControllerCompat
val mediaController = MediaControllerCompat(
this@MediaPlayerActivity, // Context
token
)
// Save the controller
MediaControllerCompat.setMediaController(this@MediaPlayerActivity, mediaController)
}
// Finish building the UI
buildTransportControls()
}
override fun onConnectionSuspended() {
// The Service has crashed. Disable transport controls until it automatically reconnects
}
override fun onConnectionFailed() {
// The Service has refused our connection
}
}
바로 위 ConnectionCallback 예제에서 buildTransportControls() 메서드를 볼 수 있었을 것이다.
플레이어를 컨트롤하기 위한 UI elements를 위해 onClickListeners를 설정해야 하는데,
다음 예제에서는 이를 보여줄 것이다.
fun buildTransportControls() {
val mediaController = MediaControllerCompat.getMediaController(this@MediaPlayerActivity)
// Grab the view for the play/pause button
playPause = findViewById<ImageView>(R.id.play_pause).apply {
setOnClickListener {
// Since this is a play/pause button, you'll need to test the current state
// and choose the action accordingly
val pbState = mediaController.playbackState.state
if (pbState == PlaybackStateCompat.STATE_PLAYING) {
mediaController.transportControls.pause()
} else {
mediaController.transportControls.play()
}
}
}
// Display the initial state
val metadata = mediaController.metadata
val pbState = mediaController.playbackState
// Register a Callback to stay in sync
mediaController.registerCallback(controllerCallback)
}
코드를 읽어보면 playbackState가 PlaybackStateCompat.STATE_PLAYING일 때에는 pause를 보여주도록, 반대로 PlaybackStateCompat.STATE_PAUSED일 때에는 play를 보여주도록 하고 있다!
당연한 예시이다...
참고로 TransportControls 메서드는 service의 media session에 콜백을 보낸다.
UI는 PlaybackState과 Metadatamedia에서 보여주는 session의 현재 state를 그대로 화면에 표시해줘야 한다.
(예를 들어 음악이 재생중인지, 일시정지중인지 등)
transport controls를 만든다면 session의 현재 state를 가져와 UI를 업데이트할 수 있고, state에 따라 transport controls를 enable/disable할 수 있다.
media session으로부터 state와 metadata 값이 변할 때마다 콜백을 받기 위해서는 다음과 같이 MediaControllerCompat.Callback 을 정의해야한다.
private var controllerCallback = object : MediaControllerCompat.Callback() {
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {}
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {}
}
transport controls를 만들 때 콜백을 등록하고, activity가 중지될 때 등록 해제를 해주면 된다.
media session이 invalid해지면, onSessionDestroyed() 콜백이 호출된다.
그렇게 된다면 MediaBrowserService 생명주기 안에서 session은 더이상 사용될 수 없다.
그렇기 때문에 session이 destroyed된다면 반드시 disconnect() 메서드를 호출하여
MediaBrowserService와의 연결을 해제해야 한다.
다음 예제 코드는 그 예시를 보여준다.
private var controllerCallback = object : MediaControllerCompat.Callback() {
override fun onSessionDestroyed() {
mediaBrowser.disconnect()
// maybe schedule a reconnection using a new MediaBrowser instance
}
}
다음은 콜백 샘플 예제이다.
private val intentFilter = IntentFilter(ACTION_AUDIO_BECOMING_NOISY)
// Defined elsewhere...
private lateinit var afChangeListener: AudioManager.OnAudioFocusChangeListener
private val myNoisyAudioStreamReceiver = BecomingNoisyReceiver()
private lateinit var myPlayerNotification: MediaStyleNotification
private lateinit var mediaSession: MediaSessionCompat
private lateinit var service: MediaBrowserService
private lateinit var player: SomeKindOfPlayer
private lateinit var audioFocusRequest: AudioFocusRequest
private val callback = object: MediaSessionCompat.Callback() {
override fun onPlay() {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Request audio focus for playback, this registers the afChangeListener
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run {
setOnAudioFocusChangeListener(afChangeListener)
setAudioAttributes(AudioAttributes.Builder().run {
setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
build()
})
build()
}
val result = am.requestAudioFocus(audioFocusRequest)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
// Start the service
startService(Intent(context, MediaBrowserService::class.java))
// Set the session active (and update metadata and state)
mediaSession.isActive = true
// start the player (custom call)
player.start()
// Register BECOME_NOISY BroadcastReceiver
registerReceiver(myNoisyAudioStreamReceiver, intentFilter)
// Put the service in the foreground, post notification
service.startForeground(id, myPlayerNotification)
}
}
}
public override fun onStop() {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Abandon audio focus
am.abandonAudioFocusRequest(audioFocusRequest)
unregisterReceiver(myNoisyAudioStreamReceiver)
// Stop the service
service.stopSelf()
// Set the session inactive (and update metadata and state)
mediaSession.isActive = false
// stop the player (custom call)
player.stop()
// Take the service out of the foreground
service.stopForeground(false)
}
public override fun onPause() {
val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// Update metadata and state
// pause the player (custom call)
player.pause()
// unregister BECOME_NOISY BroadcastReceiver
unregisterReceiver(myNoisyAudioStreamReceiver)
// Take the service out of the foreground, retain the notification
service.stopForeground(false)
}