如何使用MediaPlayer-MediaBrowserService

如何使用MediaPlayer-MediaBrowserService

如果要寫一個音樂播放器,首先你要知道有哪些多媒體目錄,所以 Google 提供了一個類別叫做MediaBrowserService 讓你可以輕鬆操作多媒體相關資料,比如說你可以瀏覽或播放相關的多媒體資源。

說明

如果你要使用 MediaBrowserService 的話,就必須在 Gradle 內加入以下宣告。

implementation 'androidx.media:media:1.1.0'

一開始要先宣告 MediaBrowserService 在你的 App 裡面,一開始你就要在 manifest 裡面寫好 filter。

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

MediaBrowserService 的實作類別為 MediaBrowserServiceCompat,透過 MediaBrowserService 可以控制多媒體資料是否要播放。

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

一開始我們宣告一個類別 MediaPlaybackService 讓它繼承 MediaBrowserServiceCompat,如同前面所講,你必須實作 MediaSessionCompat,並且設定 SessionCallback 以及 SessionToken。

PlaybackStateCompat 這個類別專門為了 MediaSessionCompat 的狀態設計出來的,它可以讓 MediaSession 得知目前進度、播放狀態以及目前控制的狀態。

MediaBrowserService 有兩個方法必須要覆寫,一個是 onGetRoot 另外一個是 onLoadChildren。

onGetRoot 控制存取根目錄,也就是說你擁有可以瀏覽多媒體資料根節點的目錄,如果回傳 null 代表你沒辦法連接失敗,為了讓使用者能夠瀏覽所有多媒體資料的階層,onGetRoot 通常會不會是空的。

onLoadChildren 提供瀏覽子類別目錄或者播放子類別。

這邊有一個重點要注意,為了讓 onGetRoot 快點回覆一個不是空的值,我們通常不會將會處理比較久的任務放在 onGetRoot 方法內處理,如果你有必須處理比較久的邏輯,你可以考慮放在 onLoadChildren 裡面。

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 做兩個處置,當 allowBrowsing 成立與否,我們都會回傳一個非空的 BrowserRoot 物件回去。

這邊官方額外提供另外一種方法,可以參考看看 PackageValidatorUniversal Android Music Player 裡面有類似的寫法。

當然你也可以直接就回傳一個 MediaBrowserServiceCompat.BrowserRoot 物件,裡面塞好自己定義的 ROOT 識別字,因為接下來會在 onLoadChildren 進行處理相關動作。

在 onLoadChildren 方法內處理列表相關業務

當使用者可以存取根目錄以後,我們可以透過 onLoadChildren 這個方法來進行所有檔案階層的探勘,這個方法會回傳 MediaBrowser.MediaItem 物件。

每一個 MediaItem 擁有唯一的 ID 以及 Token,如果你要找到下一層的物件,你就必須傳入該層 ID,接著這個方法會再被呼叫,然後傳入下一層的 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)
}

media browser service 的生命週期

一個 Service 如果被建立而且啟動了以後,只要被一個或者多個 client 聯繫著,那麼這個 Service 就不會被銷毀,但是如果你的 Activity 連繫著一個 Service,但是沒有啟動它,而這個 Service 也沒有其他的聯繫,那麼在這個 Activity 被系統銷毀的時候,這個 Service 也會跟著銷毀了,基於這樣的一個原因,如果你想要讓使用者在使用你的音樂服務的時候,記得要啟動這個 Service,那如果你要想主動銷毀這個 Service,也可以透過呼叫這兩個方法之一 Context.stopService()stopSelf() 來達成你的需求,但是如果有其他的 client 聯繫著這個 Service,就算呼叫這兩個方法之一,也會等到沒有任何 clinet 聯繫的時候才會銷毀。

使用 MediaStyle notifications 在前景服務

當一個 Service 已經啟動了且正在執行服務,所以我們必須讓它呈現在前景服務,這樣一來它就不會在記憶體不足的時候被銷毀,一個前景服務必須設定一個 notification 讓使用者能夠持續控制,在 onPlay() callback 必須放在前景的 Service (前景服務是指使用者執行一個背景服務時仍可以看到畫面且可以控制它)。

當 Service 跑在前景服務時必須展現一個 Notification,理論上應該要有一個或多個控制項,這個 Notification 應該包含播放中相關的資訊。

當播放器正在開始播放的時候,你想要更新 Notification 資訊時,你可以宣告在 MediaSessionCompat.Callback.onPlay() 方法內,下面有一個範例可以示範怎麼放置一個 Notification。

// 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())

如果你要使用 MediaStyle notifications,你可以做以下幾種行為。

  • 設定 setContentIntent() 之後,當使用者點選 Notification 以後,會自動導到對應的頁面。

  • 設定 setVisibility(NotificationCompat.VISIBILITY_PUBLIC),在 lock screen 的時候,可以控制相關對應的控制項。

  • 注意 Notification 的顏色,5.0 跟 7.0 的設定方案不太相同。

  • 使用 setMediaSession() 同意讓第三方 App 或者裝置控制相關項目。

  • 使用 setShowActionsInCompactView 最多三個執行的按鈕顯示在 Notification 上。

  • 在 5.0 以後的版本,可以直接在 Notification 上直接停止這個 Service,但是在 5.0 以前的版本,你就必須在右上角加上一個關閉的小圖示,並且使用 setShowCancelButton(true)setCancelButtonIntent() 這兩個方法來處理關閉。

當你需要加入暫停或取消的按鈕時,你要透過 PendingIntent 搭配 playback。

// Add a cancel button
.setShowCancelButton(true)
.setCancelButtonIntent(
  MediaButtonReceiver.buildMediaButtonPendingIntent(
    context,
    PlaybackStateCompat.ACTION_STOP
  )
)

參考連結

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