MediaBrowserServiceCompat and the modern media playback app
Media apps, more so than most, benefit strongly from working with the Android system and other apps. Some things, like handling interruptions with audio focus, have been a constant since nearly the beginning of Android and are just as important now as ever. While Media Playback with MediaSessionCompat is relatively new, it provides a consistent way of talking to the system across all API 4+ API levels.
But none of that touches on perhaps the biggest part: getting your fancy MediaSessionCompat-using service to talk to your UI or really anyone else. This is where MediaBrowserServiceCompat steps in — a new addition in version 23.2 of the Android Support Library.
MediaBrowserServiceCompat and MediaBrowserCompat serve as a pre-built communication protocol between your media playback Service and other components.
Let’s break it down
MediaBrowserServiceCompat is a long name, I’ll give you that. But most of it should be self-explanatory:
- Media: this class is specifically for media playback apps, with more focus on audio playback
- Browser: this is the new part, which we’ll get into more detail below
- Service: this is a subclass of Service that you’d use as the base class for your media playback service (the Service managing background playback)
- Compat: Unlike MediaBrowserService (added in Lollipop), this version is part of Support v4 and supports all the way back to API 4
So if MediaBrowserServiceCompat is your Service subclass, the other half of the communication protocol (i.e., what your UI will use to connect to a MediaBrowserServiceCompat) is MediaBrowserCompat. No need to write custom actions, manage Bundles and Intents, or any of that!
An important point on the Compat side: these Compat classes are cross-compatible with their framework versions. That means you can use MediaBrowserCompat to connect to an app using MediaBrowserService or use a MediaBrowserServiceCompat and still handle apps connecting to your Service using MediaBrowser. It just works.
There is one significant benefit of using both MediaBrowserServiceCompat and MediaBrowserCompat together though — you’ll be able to use the latest Marshmallow APIs on Lollipop devices as well. #magic
The basics
Let’s put the ‘Browser’ part to the side for a second and look at the basics: connecting a MediaBrowserServiceCompat and a MediaBrowserCompat.
The changes to your Service including adding an intent filter:
<service android:name=".MediaPlaybackService"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
You’ll also need to update your code slightly:
public class MediaPlaybackService extends MediaBrowserServiceCompat {
private MediaSessionCompat mMediaSession; @Override
public void onCreate() {
super.onCreate();
// Create your MediaSessionCompat.
// You should already be doing this
mMediaSession = new MediaSessionCompat(this,
MediaPlaybackService.class.getSimpleName()); // Make sure to configure your MediaSessionCompat as per
// https://www.youtube.com/watch?v=FBC1FgWe5X4 setSessionToken(mMediaSession.getSessionToken());
} @Override
public BrowserRoot onGetRoot(@NonNull String clientPackageName,
int clientUid, Bundle rootHints) {
// Returning null == no one can connect
// so we’ll return something
return new BrowserRoot(
getString(R.string.app_name), // Name visible in Android Auto
null); // Bundle of optional extras
} @Override
public void onLoadChildren(String parentId,
Result<List<MediaBrowserCompat.MediaItem>> result) {
// I promise we’ll get to browsing
result.sendResult(null);
}
}
Yes, we have to implement two new (abstract) methods. The only other change is calling setSessionToken() — preferably in your onCreate() method but basically as soon as possible. You shouldn’t wait for playback to start because we’re going to need that token on the MediaBrowserCompat side:
private MediaBrowserCompat mMediaBrowser;@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// The usual setContentView, etc
// Now create the MediaBrowserCompat
mMediaBrowser = new MediaBrowserCompat(
this, // a Context
new ComponentName(this, MediaPlaybackService.class),
// Which MediaBrowserService
new MediaBrowserCompat.ConnectionCallback() {
@Override
public void onConnected() {
try {
// Ah, here’s our Token again
MediaSessionCompat.Token token =
mMediaBrowser.getSessionToken();
// This is what gives us access to everything
MediaControllerCompat controller =
new MediaControllerCompat(MainActivity.this, token); // Convenience method to allow you to use
// MediaControllerCompat.getMediaController() anywhere
MediaControllerCompat.setMediaController(
MainActivity.this, controller);
} catch (RemoteException e) {
Log.e(MainActivity.class.getSimpleName(),
"Error creating controller", e);
}
} @Override
public void onConnectionSuspended() {
// We were connected, but no longer :-(
} @Override
public void onConnectionFailed() {
// The attempt to connect failed completely.
// Check the ComponentName!
}
},
null); // optional Bundle
mMediaBrowser.connect();
}@Override
protected void onDestroy() {
super.onDestroy();
mMediaBrowser.disconnect();
}
So we specifically call out a ComponentName to connect to and use that to connect to the MediaBrowserServiceCompat. As you might have guessed, this wraps the API for bound services, which makes sense since we’re trying to connect to a Service. Once connected, we’ll gain access to the MediaSessionCompat.Token. The Token is what allows us to then create a MediaControllerCompat.
Note: in this case, we connected from within a FragmentActivity, but that isn’t a hard requirement. It could be a Fragment or Loader just as easily — just make sure you use an application context if you are using a retained fragment/Loader.
Keep in mind that a bound service is destroyed when its last client unbinds. While this is normally fine if you are actually using a MediaBrowserServiceCompat for browsing, you should call startService() when you start media playback and stopSelf() when media playback is stopped to ensure your media playback isn’t interrupted by clients unbinding/rebinding — such as would happen in configuration changes. See the example of Universal Android Music Player’s MusicService, which handles this case correctly.
The power of MediaControllerCompat
All that and all we got was a MediaControllerCompat. Thankfully, a MediaControllerCompat is enough information to build an entire UI.
You’ll be able to get all of the current metadata with getMetadata(). This includes information like the current artist, album, track name, rating, album art, and whatever else you pass into setMetadata() on the service side.
Using getPlaybackState(), you’ll find out what the current state (fancy that) of playback is — playing, paused, etc. as well as what actions (such as skip to next) are supported.
Both of these are just point in time readings though. But rather than have you continuously poll these values, there’s a MediaControllerCompat.Callback you can pass to registerCallback(). You’ll get a callback whenever anything changes, allowing your UI to stay in sync with your Service.
Of course, this is a two way channel and we haven’t talked about how to get information to your Service — getTransportControls() fills that void, giving you methods to trigger any action (including custom actions specific to your media playback such as ‘skip forward 30 seconds’). All of which directly trigger the methods in your MediaSessionCompat.Callback in your Service.
There’s more to it as well (volume adjustments, queue management) so I’d encourage you to look at it in full — you might have your own custom system that can replaced in its entirety (saving you code maintenance!).
Browsing
So far, we’ve been focusing on the bare minimum needed to get the connection working. And sure, that gave you a lot to play with already. But we wouldn’t call it MediaBrowserServiceCompat if there wasn’t some browsing included. Browsing allows those connected to your MediaBrowserServiceCompat get a list of media items you have available as a basic hierarchy, starting with the root and working down one or more levels of children.
While totally optional for building your own UI, browsing is the primary way the UI on Android Auto is built. Just add Compat to everything on the providing audio playback for Auto training. You’ll note that Android Wear will provide a Browse action on your playback notification as well.
onGetRoot()
Everything starts at the root. As mentioned in the code above, you must return a non-null BrowserRoot to allow connections to your MediaBrowserServiceCompat. This fact means that choosing what root to return is actually quite a big deal.
The Universal Android Music Player sample app (UAMP) contains a handy PackageValidator class that you can use to allow your own app, Android Auto, and Android Wear to connect — this would be a ‘whitelist’ style model. Whether you chose to use a whitelist or allow access to all apps is more of a business decision than a technical decision.
The other thing to keep in mind when returning a root is that this is the one place you get to change behavior based on the connecting app. For example, you could use UAMP’s CarHelper or WearHelper as an example of checking the incoming package name and return different roots.
Returning different roots can make a huge difference in user experience. For example, let us assume you are going to be browsing your own media and want to support Android Auto. A simple approach may have you returning the same hierarchy for both. With safety on the line in the Android Auto case, having a deep hierarchy or arbitrarily long lists (such as an alphabetized list of artists) can lead to a frustrated driver. Consider simplifying your hierarchy or emphasizing the most likely content (a Recents list at the top can be a huge help!) to minimize the number of steps between the user and their album/song of choice.
On the MediaBrowserCompat side, you’ll use getRoot() to get the root id. Easy enough.
onLoadChildren()
Once you’ve returned a root, that root id will be passed as the parentMediaId to onLoadChildren(). Here is where you load all of the direct children of the parentMediaId.
On first glance, the parameters to the method might seem a bit odd (I know they threw me for a loop). Instead of having a direct return value, you’ll actually be returning the value by calling Result.sendResult(). This mechanism allows you to immediately return a value (just like a return value) or call detach(), push the result to another thread, and only call sendResult() when you’ve loaded all of the items. This is critical to do if you need to pull information from the network — don’t hold everything up by loading it all in place!
Note: You’ll find that you’ll get an IllegalStateException if you fail to call detach() or sendResult() before returning. This is 100% expected. Make sure every code path calls one or the other.
Each item returned is a MediaItem and each MediaItem consists of a MediaDescriptionCompat (a subset of metadata) and some combination of the two available flags:
- FLAG_BROWSABLE indicates that this MediaItem has children of its own (i.e., its media id can be passed to onLoadChildren() to get more MediaItems.
- FLAG_PLAYABLE should be used when this MediaItem can be directly played (i.e., passed to playFromMediaId() to start playback)
Retrieving children on the MediaBrowserCompat side involves a call to subscribe(). You’ll get a callback whenever the children change (i.e., when the service calls notifyChildrenChanged()):
String root = mediaBrowser.getRoot();
mediaBrowser.subscribe(root,
new MediaBrowserCompat.SubscriptionCallback() {
@Override
public void onChildrenLoaded(@NonNull String parentId,
List<MediaBrowserCompat.MediaItem> children) {
if (children == null || children.isEmpty()) {
return;
}
MediaBrowserCompat.MediaItem firstItem = children.get(0);
// Play the first item?
// Probably should check firstItem.isPlayable()
MediaControllerCompat.getMediaController(MainActivity.this)
.getTransportControls()
.playFromMediaId(firstItem.getMediaId(), null);
}
});
There’s an equivalent unsubscribe() when you’d no longer like callbacks.
onLoadItem()
onLoadItem() is the newcomer to the MediaBrowserService APIs, only being added in Marshmallow. This a convenience method that allows connected MediaBrowserCompat instances to retrieve the MediaItem associated with just a single media id.
You’ll note that the default implementation just returns null. Since this allows much more efficient querying of a single element, please implement it! Given that this should be returning the same MediaItem as from the parent’s onLoadChildren(), you should be able to reuse much of the logic.
You’ll use getItem() in your connected MediaBrowserCompat to retrieve the specific item. The callback onError() is used when you aren’t connected or something horrible happened on the other end — in most cases you’ll get a callback to onItemLoaded().
I could go on
There’s a strong chance that most of what I’ve talked about is something an existing media playback app is already doing: sending commands to your Service, broadcasting changes out to update your UI, storing and retrieving a hierarchy of media items, etc. Believe me, I wouldn’t be talking about it all if it wasn’t critical to a great media app.
But is writing and maintaining this logic really your unique defining feature compared to competitors? I should hope not.
Are you still going to get requests to support Android Auto, which requires you to use MediaBrowserService? Probably. Are we going to build more on this API? Yes, yes we are. (We already added Android Wear.)
You’ll find that UAMP has been fully upgraded to take advantage of MediaBrowserServiceCompat as well as MediaSessionCompat so check out the full source code!
If you’ve been procrastinating looking at the API or integrating it into your app, there’s no better time than right now to take a look and #BuildBetterApps
Follow the Android Development Patterns Collection for more!