建立媒體瀏覽器服務

應用程式必須在資訊清單中使用意圖篩選器宣告 MediaBrowserService,您可以自行選擇服務名稱,在以下範例中為「MediaPlaybackService」。

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

注意: 建議的 MediaBrowserService 實作方式為 MediaBrowserServiceCompatmedia-compat 支援資料庫中的定義。在這個頁面中,「MediaBrowserService」一詞是指 MediaBrowserServiceCompat 的例項。

初始化媒體工作階段

服務收到 onCreate() 生命週期回呼方法時,應執行下列步驟:

下列 onCreate() 程式碼示範了這些步驟:

Kotlin

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

Java

public class MediaPlaybackService extends MediaBrowserServiceCompat {
    private static final String MY_MEDIA_ROOT_ID = "media_root_id";
    private static final String MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id";

    private MediaSessionCompat mediaSession;
    private PlaybackStateCompat.Builder stateBuilder;

    @Override
    public void onCreate() {
        super.onCreate();

        // Create a MediaSessionCompat
        mediaSession = new MediaSessionCompat(context, LOG_TAG);

        // Enable callbacks from MediaButtons and TransportControls
        mediaSession.setFlags(
              MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
              MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);

        // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
        stateBuilder = new PlaybackStateCompat.Builder()
                            .setActions(
                                PlaybackStateCompat.ACTION_PLAY |
                                PlaybackStateCompat.ACTION_PLAY_PAUSE);
        mediaSession.setPlaybackState(stateBuilder.build());

        // MySessionCallback() has methods that handle callbacks from a media controller
        mediaSession.setCallback(new MySessionCallback());

        // Set the session's token so that client activities can communicate with it.
        setSessionToken(mediaSession.getSessionToken());
    }
}

管理用戶端連線

MediaBrowserService 有兩種處理用戶端連線的方法:onGetRoot() 會控制服務的存取權,onLoadChildren() 則可讓用戶端建構並顯示 MediaBrowserService 內容階層的選單。

控管與 onGetRoot() 的用戶端連線

onGetRoot() 方法會傳回內容階層的根節點。如果方法傳回空值,系統就會拒絕連線。

若要允許用戶端連線至您的服務並瀏覽其媒體內容,onGetRoot() 必須傳回非空值 BrowserRoot,也就是代表內容階層的根 ID。

如要讓用戶端在不瀏覽的情況下連線至 MediaSession,onGetRoot() 仍必須傳回非空值的 BrowserRoot,但根 ID 應代表空白的內容階層。

onGetRoot() 的典型實作方式可能會像這樣:

Kotlin

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 hierarchy
        // so onLoadChildren returns nothing. This disables the ability to browse for content.
        MediaBrowserServiceCompat.BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null)
    }
}

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {

    // (Optional) Control the level of access for the specified package name.
    // You'll need to write your own logic to do this.
    if (allowBrowsing(clientPackageName, clientUid)) {
        // Returns a root ID that clients can use with onLoadChildren() to retrieve
        // the content hierarchy.
        return new BrowserRoot(MY_MEDIA_ROOT_ID, null);
    } else {
        // Clients can connect, but this BrowserRoot is an empty hierarchy
        // so onLoadChildren returns nothing. This disables the ability to browse for content.
        return new BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null);
    }
}

在某些情況下,您可能想要控管誰可以連線至您的 MediaBrowserService。其中一種方法是使用存取控制清單 (ACL) 來指定允許的連線,或另外列舉應禁止的連線。如需如何實作允許特定連線的 ACL 範例,請參閱「通用 Android 音樂播放器」範例應用程式中的 PackageValidator 類別。

建議您根據用戶端的類型來提供不同的內容階層。具體來說,Android Auto 會限制使用者與音訊應用程式互動的方式。詳情請參閱「播放自動音訊」。您可以查看連線時的 clientPackageName,判斷用戶端類型,並根據用戶端傳回不同的 BrowserRoot (或 rootHints)。

與「onLoadChildren()」通訊內容

用戶端連線後,只要重複呼叫 MediaBrowserCompat.subscribe() 即可建構 UI 的本機表示法,藉此掃遍內容階層。subscribe() 方法會將回呼 onLoadChildren() 傳送至服務,進而傳回 MediaBrowser.MediaItem 物件清單。

每個 MediaItem 都有一個專屬 ID 字串,這是一個不透明權杖。用戶端想要開啟子選單或播放項目時,就會傳遞 ID。您的服務必須負責將 ID 與適當的選單節點或內容項目建立關聯。

簡易的 onLoadChildren() 實作可能會如下所示:

Kotlin

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

Java

@Override
public void onLoadChildren(final String parentMediaId,
    final Result<List<MediaItem>> result) {

    //  Browsing not allowed
    if (TextUtils.equals(MY_EMPTY_MEDIA_ROOT_ID, parentMediaId)) {
        result.sendResult(null);
        return;
    }

    // Assume for example that the music catalog is already loaded/cached.

    List<MediaItem> mediaItems = new ArrayList<>();

    // Check if this is the root menu:
    if (MY_MEDIA_ROOT_ID.equals(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);
}

注意: MediaBrowserService 提供的 MediaItem 物件不應包含圖示點陣圖。為每個項目建構 MediaDescription 時,請改用 Uri,改為呼叫 setIconUri()

如需 onLoadChildren() 的實作範例,請參閱「通用 Android 音樂播放器」範例應用程式。

媒體瀏覽器服務生命週期

Android 服務的行為取決於服務「啟動」或「繫結」至一或多個用戶端。建立服務後,服務就能開始和/或繫結。上述所有狀態都能正常運作,並能執行其設計的工作。差別在於服務存在的時間長度。除非繫結的用戶端全都解除繫結,否則系統不會刪除繫結服務。啟動的服務可明確停止並銷毀 (假設服務不再與任何用戶端繫結)。

當在其他活動中執行的 MediaBrowser 連線至 MediaBrowserService 時,會將活動繫結至服務,使服務繫結 (但不會啟動)。這個預設行為內建於 MediaBrowserServiceCompat 類別內。

僅在 (未啟動) 繫結的服務會在所有用戶端解除繫結後就會遭到刪除。如果此時使用者介面活動中斷連線,服務就會遭到刪除。不過,如果您尚未播放任何音樂,這也不是問題。不過,開始播放後,使用者可能會希望在切換應用程式後繼續聆聽音樂。當您取消繫結使用者介面並搭配其他應用程式使用時,也不想刪除播放器。

因此,您必須呼叫 startService(),確保服務會在開始播放時啟動。無論啟動的服務是否繫結,都必須明確停止。這樣可確保在控制 UI 活動取消繫結時,玩家仍可繼續執行。

如要停止已啟動的服務,請呼叫 Context.stopService()stopSelf()。系統會盡快停止並刪除服務。不過,如果一或多個用戶端仍繫結至服務,則停止服務的呼叫會延遲,直到其所有用戶端解除繫結為止。

MediaBrowserService 的生命週期會受到建立方式、繫結的用戶端數量,以及從媒體工作階段回呼接收的呼叫。總結:

  • 當服務因回應媒體按鈕而啟動,或活動繫結至該服務 (透過其 MediaBrowser 連線後) 時,系統就會建立服務。
  • 媒體工作階段 onPlay() 回呼應包含呼叫 startService() 的程式碼。這可確保服務啟動並持續執行,即使所有繫結的 UI MediaBrowser 活動與該服務解除繫結也一樣。
  • onStop() 回呼應呼叫 stopSelf()。如果服務已啟動,這麼做會停止服務。此外,如果沒有繫結的活動,服務將會遭到刪除。否則,服務會維持繫結,直到其所有活動解除繫結為止。(如果在服務刪除前收到後續的 startService() 呼叫,則待處理的停止作業會遭到取消)。

以下流程圖說明瞭服務生命週期的管理方式。變數計數器會追蹤用戶端的數目:

服務生命週期

搭配前景服務使用 MediaStyle 通知

服務播放時,應在前景執行。這會讓系統知道服務正在執行實用函式,在系統記憶體不足時不應終止。前景服務必須顯示通知,讓使用者知道通知內容,並視需要控制。onPlay() 回呼應將服務置於前景。(請注意,這是「前景」的特殊意義。雖然 Android 在處理處理程序時將服務視為在前景運作,但畫面的「前景」顯示某些其他應用程式,而使用者是在背景中播放。)

當服務在前景執行時,必須顯示通知,最好使用一或多個傳輸控制項。通知也應包含工作階段中繼資料的實用資訊。

建立並在玩家開始播放時顯示通知。最佳做法是在 MediaSessionCompat.Callback.onPlay() 方法中執行這項操作。

以下範例使用專為媒體應用程式設計的 NotificationCompat.MediaStyle。其中說明如何建構顯示中繼資料和傳輸控制項的通知。便利方法 getController() 可讓您直接從媒體工作階段建立媒體控制器。

Kotlin

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

Java

// Given a media session and its context (usually the component containing the session)
// Create a NotificationCompat.Builder

// Get the session's metadata
MediaControllerCompat controller = mediaSession.getController();
MediaMetadataCompat mediaMetadata = controller.getMetadata();
MediaDescriptionCompat description = mediaMetadata.getDescription();

NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);

builder
    // Add the metadata for the currently playing track
    .setContentTitle(description.getTitle())
    .setContentText(description.getSubtitle())
    .setSubText(description.getDescription())
    .setLargeIcon(description.getIconBitmap())

    // Enable launching the player by clicking the notification
    .setContentIntent(controller.getSessionActivity())

    // 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)
    .setColor(ContextCompat.getColor(context, R.color.primaryDark))

    // Add a pause button
    .addAction(new NotificationCompat.Action(
        R.drawable.pause, getString(R.string.pause),
        MediaButtonReceiver.buildMediaButtonPendingIntent(context,
            PlaybackStateCompat.ACTION_PLAY_PAUSE)))

    // Take advantage of MediaStyle features
    .setStyle(new MediaStyle()
        .setMediaSession(mediaSession.getSessionToken())
        .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 通知時,請注意以下 NotificationCompat 設定的行為:

  • 使用 setContentIntent() 時,您的服務會在使用者點選通知時自動啟動,這是一項實用功能。
  • 在「不受信任」的情況 (例如螢幕鎖定畫面) 中,通知內容的預設瀏覽權限為 VISIBILITY_PRIVATE。您可能會希望在螢幕鎖定畫面上看到傳輸控制項,因此使用 VISIBILITY_PUBLIC 是不錯的選擇。
  • 設定背景顏色時請務必小心。在 Android 5.0 以上版本的一般通知中,顏色只會套用至小型應用程式圖示的背景。但是,如果是 Android 7.0 之前的 MediaStyle 通知,整個通知背景都會使用這個顏色。測試背景顏色。請輕輕擦掉眼睛,避免極度明亮的或螢光燈色。

您必須使用 NotificationCompat.MediaStyle,才能使用這些設定:

  • 請使用 setMediaSession() 將通知與工作階段建立關聯。這樣一來,第三方應用程式和隨附裝置就能存取及控制工作階段。
  • 使用 setShowActionsInCompactView() 新增最多 3 個動作,以顯示在通知的標準大小 contentView 中。(這裡指定了暫停按鈕)。
  • 在 Android 5.0 (API 等級 21) 以上版本中,只要在前景服務不再執行服務後,滑開通知即可停止玩家。您無法在較舊的版本中進行此操作。如要允許使用者在 Android 5.0 (API 級別 21) 之前移除通知並停止播放,您可以呼叫 setShowCancelButton(true)setCancelButtonIntent(),在通知的右上角新增取消按鈕。

新增「暫停」和「取消」按鈕時,您需要用 PendingIntent 附加至播放動作。MediaButtonReceiver.buildMediaButtonPendingIntent() 方法會執行將 PlaybackState 動作轉換為 PendingIntent 的工作。