פיתוח שירות לדפדפן מדיה

האפליקציה חייבת להצהיר על MediaBrowserService עם מסנן Intent במניפסט שלה. אתם יכולים לבחור שם שירות משלכם. בדוגמה הבאה, השם של השירות שנבחר הוא MediaPlaybackService.

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

הפעלת סשן המדיה

כשמקבלים את שיטת הקריאה החוזרת במחזור חיים 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() מחזירה את צומת הבסיס של היררכיית התוכן. אם הפונקציה מחזירה ערך null, החיבור נדחה.

כדי לאפשר ללקוחות להתחבר לשירות ולעיין בתוכן המדיה שלו, onGetRoot() צריך להחזיר BrowserRoot שאינו null, שהוא מזהה בסיס שמייצג את היררכיית התוכן.

כדי לאפשר ללקוחות להתחבר ל-MediaSession בלי לגלוש, onGetRoot() עדיין צריך להחזיר BrowserRoot שאינו null, אבל מזהה הבסיס צריך לייצג היררכיית תוכן ריקה.

יישום טיפוסי של 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 שמאפשרת חיבורים ספציפיים מופיעה במחלקה PackageValidator באפליקציית הדוגמה Universal Android Music Player.

כדאי לספק היררכיות שונות של תוכן בהתאם לסוג הלקוח שמבצע את השאילתה. בפרט, Android Auto מגביל את האינטראקציה של המשתמשים עם אפליקציות אודיו. מידע נוסף זמין במאמר בנושא הפעלת אודיו ב-Auto. אפשר לבדוק את clientPackageName בזמן החיבור כדי לקבוע את סוג הלקוח, ולהחזיר BrowserRoot שונה בהתאם ללקוח (או rootHints אם יש).

העברת תוכן עם onLoadChildren()

אחרי שהלקוח מתחבר, הוא יכול לעבור בהיררכיית התוכן על ידי ביצוע שיחות חוזרות אל MediaBrowserCompat.subscribe() כדי ליצור ייצוג מקומי של ממשק המשתמש. השיטה subscribe() שולחת את הקריאה החוזרת onLoadChildren() לשירות, שמחזיר רשימה של אובייקטים MediaBrowser.MediaItem.

לכל MediaItem יש מחרוזת מזהה ייחודית, שהיא אסימון אטום. כשלקוח רוצה לפתוח תפריט משנה או להפעיל פריט, הוא מעביר את המזהה. השירות שלכם אחראי לשיוך המזהה לצומת התפריט או לפריט התוכן המתאים.

יישום פשוט של 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);
}

הערה: אסור שאובייקטים מסוג MediaItem שמועברים על ידי MediaBrowserService יכילו מפות סיביות של סמלים. במקום זאת, אפשר להשתמש ב-Uri על ידי התקשרות אל setIconUri() כשיוצרים את MediaDescription לכל פריט.

דוגמה להטמעה של onLoadChildren() מופיעה באפליקציית הדוגמה Universal Android Music Player.

מחזור החיים של שירות דפדפן המדיה

ההתנהגות של שירות ב-Android תלויה בשאלה אם הוא מופעל או מקשר ללקוח אחד או יותר. אחרי שיוצרים שירות, אפשר להפעיל אותו, לקשר אותו או גם וגם. בכל המצבים האלה, השירות פועל באופן מלא ויכול לבצע את העבודה שהוא מיועד לבצע. ההבדל הוא משך הזמן שבו השירות יתקיים. שרת מאגד (bound service) לא נהרס עד שכל הלקוחות המקושרים שלו מבטלים את הקישור. אפשר לעצור ולהשמיד שירות שהופעל (בהנחה שהוא כבר לא מקושר לאף לקוח).

כש-MediaBrowser שפועל בפעילות אחרת מתחבר ל-MediaBrowserService, הפעילות נקשרת לשירות, והשירות הופך לשירות קשור (אבל לא מופעל). התנהגות ברירת המחדל הזו מוטמעת בכיתה MediaBrowserServiceCompat.

שירות שרק נקשר (ולא הופעל) נהרס כשכל הלקוחות שלו מבטלים את הקישור. אם הפעילות בממשק המשתמש מתנתקת בשלב הזה, השירות נהרס. אם עדיין לא הפעלתם מוזיקה, זה לא אמור להפריע. עם זאת, כשמתחילה ההפעלה, המשתמש כנראה מצפה להמשיך להאזין גם אחרי מעבר בין אפליקציות. אתם לא רוצים להרוס את הנגן כשמבטלים את הקישור של ממשק המשתמש כדי לעבוד עם אפליקציה אחרת.

לכן, צריך לוודא שהשירות מופעל כשהוא מתחיל לפעול על ידי התקשרות אל startService(). צריך לעצור שירות שהופעל באופן מפורש, בין אם הוא מקושר ובין אם לא. כך הנגן ימשיך לפעול גם אם הפעילות של ממשק המשתמש לשליטה תבוטל.

כדי להפסיק שירות שהתחיל, מתקשרים אל Context.stopService() או אל stopSelf(). המערכת מפסיקה את השירות ומשמידה אותו בהקדם האפשרי. עם זאת, אם לקוח אחד או יותר עדיין מקושרים לשירות, הקריאה להפסקת השירות מתעכבת עד שכל הלקוחות יבטלו את הקישור.

מחזור החיים של MediaBrowserService נשלט על ידי אופן היצירה שלו, מספר הלקוחות שמקושרים אליו והקריאות שהוא מקבל מקריאות חוזרות של סשן מדיה. לסיכום:

  • השירות נוצר כשהוא מופעל בתגובה ללחצן מדיה או כשפעילות נקשרת אליו (אחרי התחברות דרך MediaBrowser).
  • הקריאה החוזרת (callback) של סשן המדיה onPlay() צריכה לכלול קוד שקורא ל-startService(). כך השירות מתחיל לפעול וממשיך לפעול, גם כשכל הפעילויות של ממשק המשתמש MediaBrowser שמשויכות אליו מבוטלות.
  • הקריאה החוזרת onStop() צריכה להפעיל את stopSelf(). אם השירות הופעל, הפעולה הזו תגרום להפסקת הפעילות שלו. בנוסף, השירות מושבת אם לא מתבצעות בו פעילויות. אחרת, השירות יישאר קשור עד שכל הפעילויות שלו יבוטלו. (אם מתקבלת שיחה נוספת אל startService() לפני שהשירות מושבת, ההמתנה להפסקה מבוטלת).

בתרשים הזרימה הבא מוצג אופן הניהול של מחזור החיים של שירות. המשתנה counter עוקב אחרי מספר הלקוחות שמשויכים:

מחזור החיים של שירות

שימוש בהתראות MediaStyle עם שירות שפועל בחזית

כששירות פועל, הוא צריך לפעול בחזית. כך המערכת יודעת שהשירות מבצע פונקציה שימושית ואין להרוג אותו אם הזיכרון של המערכת נמוך. שירות שפועל בחזית חייב להציג התראה כדי שהמשתמש ידע על כך ויוכל לשלוט בו (אם רוצה). פונקציית ה-callback‏ onPlay() צריכה להעביר את השירות לחזית. (שימו לב שזהו משמעות מיוחדת של המילה 'חזית'. למרות שמערכת Android מחשיבה את השירות כפועל בחזית לצורך ניהול התהליך, מבחינת המשתמש הנגן פועל ברקע בזמן שאפליקציה אחרת גלויה ב'חזית' על המסך).

כששירות פועל בחזית, הוא חייב להציג התראה, ועדיף שיוצגו בה אמצעי בקרה להפעלה, להשהיה ולדילוג. ההתראה צריכה לכלול גם מידע שימושי מהמטא-נתונים של הסשן.

יצירה והצגה של ההתראה כשהנגן מתחיל לפעול. המיקום הכי טוב לעשות את זה הוא בתוך method‏ 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 ואילך, הצבע מוחל רק על הרקע של סמל האפליקציה הקטן. אבל בהתראות MediaStyle בגרסאות Android קודמות ל-7.0, הצבע משמש לרקע של כל ההתראה. בודקים את צבע הרקע. כדאי לבחור צבעים שלא מעייפים את העיניים, ולהימנע מצבעים בהירים מדי או פלואורסנטיים.

ההגדרות האלה זמינות רק כשמשתמשים ב-NotificationCompat.MediaStyle:

  • משתמשים ב-setMediaSession() כדי לשייך את ההתראה לסשן. כך אפליקציות של צד שלישי ומכשירים נלווים יכולים לגשת להפעלה ולשלוט בה.
  • אפשר להשתמש ב-setShowActionsInCompactView() כדי להוסיף עד 3 פעולות שיוצגו בתצוגת התוכן בגודל הרגיל של ההתראה. (כאן מצוין לחצן ההשהיה).
  • ב-Android מגרסה 5.0 (רמת API‏ 21) ואילך, אפשר להחליק את ההתראה כדי להפסיק את הנגן ברגע שהשירות לא פועל יותר בחזית. אי אפשר לעשות את זה בגרסאות קודמות. כדי לאפשר למשתמשים להסיר את ההתראה ולהפסיק את ההפעלה לפני Android 5.0 (רמת API‏ 21), אפשר להוסיף לחצן ביטול בפינה השמאלית העליונה של ההתראה באמצעות קריאה ל-setShowCancelButton(true) ול-setCancelButtonIntent().

כשמוסיפים את לחצני ההשהיה והביטול, צריך להוסיף PendingIntent לצירוף לפעולת ההפעלה. השיטה MediaButtonReceiver.buildMediaButtonPendingIntent() ממירה פעולה של PlaybackState ל-PendingIntent.

הפעלת גלישה במדיה באמצעות AVRCP

בנוסף לאפליקציות מותאמות אישית כמו Android Auto, שכבת ה-Bluetooth של המערכת פועלת גם כלקוח של MediaBrowserService כדי לאפשר גלישה אלחוטית בקטלוג של השלט הרחוק (AVRCP).

ב-Android 16 וב-Android 17, הפלטפורמה דורשת שאפליקציות שלא משתמשות ב-Media3 יחשפו פעילות ספציפית עם מסנן Intent כדי לאמת את הגלישה.

מוסיפים את מסנן ה-Intent הספציפי הזה לפעילות מיוצאת ב-AndroidManifest.xml. שימו לב שהשמטנו בכוונה את CATEGORY_DEFAULT כדי למנוע את הופעת האפליקציה בתפריטים כלליים של 'פתיחה באמצעות' עבור קובצי אודיו מקומיים:

<activity
    android:name=".BluetoothValidationActivity"
    android:exported="true"
    android:theme="@android:style/Theme.NoDisplay"
    android:excludeFromRecents="true"
    android:noHistory="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="content" />
    <data android:host="media" />
    <!-- Specific path check used by Bluetooth stack for validation -->
    <data android:pathPrefix="/internal/audio/media/" />
    <data android:mimeType="audio/*" />
  </intent-filter>
</activity>