음악 앱을 어떻게 설계하고 만들면 좋을까

wonseok·2022년 8월 23일
0

프로그래머스 과제테스트를 진행하다가 음악 앱에 대한 지식이 전반적으로 부족한 것 같아서
다음 Android 개발자 공식문서 - 오디오 앱 빌드 를 참고하며 공부하였다.
물론 공식문서는 무조건 영어다! hl=en으로 바꿔주자

1️⃣ 오디오 앱 개요


가장 권장되는 음악 앱의 아키텍처는 위에 첨부한 사진처럼 클라이언트 - 서버 디자인이라고 한다.

여기서 클라이언트는 MediaBrowser, MediaController, UI를 포함하고 있는 Activity 라고 할 수 있고, 서버는 Player와 MediaSession을 포함하고 있는 MediaBrowserService라고 할 수 있다.

MediaBrowserService 특징 2가지

  • MediaBrowser를 갖고 있는 다른 컴포넌트들이나 어플리케이션은 service를 탐색할 수 있고 media controller를 생성하고 media session에 연결할 수 있다.
  • 선택적 브라우징 API 제공

2️⃣ 미디어 브라우저 서비스 빌드하기

먼저 manifestintent-filter 안에 MediaBrowserService를 선언해야 한다.

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

미디어 세션 초기화

그 다음, media session을 초기화해야한다.
초기화 하는 방법은 다음과 같다.

  • media session을 만들고 초기화한다.
  • media session 콜백을 설정한다.
  • 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()는 서비스의 액세스를 제어함.
  • onLoadChildren()은 클라이언트가 콘텐츠 계층 구조 메뉴를 빌드하고 표시할 수 있는 기능을 제공함.

이 두가지 메서드를 통해 어떻게 클라이언트 연결을 처리할지 살펴보자.

onGetRoot()로 클라이언트 연결 제어

쉽게 말해 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를 반환해야 함을 알 수 있다.

onLoadChildren()으로 콘텐츠 전달

클라이언트가 연결된 후, 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)
}

포그라운드 서비스에서 MediaStyle 알림 사용

재생 중인 서비스는 포그라운드에서 실행되어야 한다.

그러면 시스템은 서비스가 유용한 기능을 실행 중임을 알게 되고 시스템 메모리가 부족해도 서비스를 종료하지 않는다.

또한, 포그라운드 서비스는 사용자가 인지하고 선택적으로 제어할 수 있도록 알림을 표시해야 한다.

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에 설정해야 하는 값들이 되게 많음을 알 수 있다.

  • setContentIntent()를 사용하면 알림을 클릭했을 때 서비스가 자동으로 시작되는 편리한 기능을 구현할 수 있다.
  • 또한 알림 콘텐츠의 기본 공개 상태는 VISIBILITY_PRIVATE인데, 잠금 화면에서도 알림을 표시하고 이를 컨트롤할 수 있게 하려면 VISIBILITY_PUBLIC을 사용하면 된다.
  • 알림을 세션에 연결하려면 setMediaSession()을 사용하면 된다.
  • API 레벨 21 이상에서는 알림을 스와이프하여 플레이어를 중지할 수 있는데, 이전 버전에서는 이렇게 할 수 없기 때문에 setShowCancelButton(true) 및 setCancelButtonIntent()를 호출하여 알림의 오른쪽 상단에 취소 단추를 추가하는 방식으로 구현해야 한다.
  • MediaButtonReceiver.buildMediaButtonPendingIntent() 메서드는 PlaybackState작업을 PendingIntent로 변환한다.

3️⃣ 미디어 브라우저 클라이언트 빌드하기

글 젤 처음에 첨부했던 클라이언트 - 서버 디자인 아키텍처 사진에서처럼 앱을 완성시키려면 Activity 컴포넌트에서 UI코드, MediaController, MediaBrowser 로직을 완성해야 한다.

MediaBrowser에는 중요한 두 가지 기능이 있다.
1. MediaBrowserService에 연결한다.
2. 연결 이후 UI의 MediaController를 만든다.

MediaBrowserService에 연결하기

먼저 예제 코드부터 살펴보자.

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 을 등록 해제한다.

MediaBrowserCompat.ConnectionCallback 커스터마이징하기

만약 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
        }
    }

미디어 컨트롤러에 UI 연결하기

바로 위 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)
}

코드를 읽어보면 playbackStatePlaybackStateCompat.STATE_PLAYING일 때에는 pause를 보여주도록, 반대로 PlaybackStateCompat.STATE_PAUSED일 때에는 play를 보여주도록 하고 있다!
당연한 예시이다...

참고로 TransportControls 메서드는 servicemedia session에 콜백을 보낸다.

미디어 세션과 동기화 유지하기 (stay in sync)

UIPlaybackStateMetadatamedia에서 보여주는 session의 현재 state를 그대로 화면에 표시해줘야 한다.
(예를 들어 음악이 재생중인지, 일시정지중인지 등)

transport controls를 만든다면 session의 현재 state를 가져와 UI를 업데이트할 수 있고, state에 따라 transport controlsenable/disable할 수 있다.

media session으로부터 statemetadata 값이 변할 때마다 콜백을 받기 위해서는 다음과 같이 MediaControllerCompat.Callback 을 정의해야한다.

private var controllerCallback = object : MediaControllerCompat.Callback() {

    override fun onMetadataChanged(metadata: MediaMetadataCompat?) {}

    override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {}
}

transport controls를 만들 때 콜백을 등록하고, activity가 중지될 때 등록 해제를 해주면 된다.

미디어 세션이 destroyed 되었을 때 연결 해제하기

media sessioninvalid해지면, 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
    }
}

4️⃣ 미디어 세션 콜백

다음은 콜백 샘플 예제이다.

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)
    }

0개의 댓글