While this is a fairly common feature, it's hard to implement, with lots of different pieces that need to be built correctly in order to give your user the full Android experience. In this tutorial you will learn about
MediaSessionCompat
from the Android support library, and how it can be used to create a proper background audio service for your users.
Project configuration
WAKE_LOCK
permission:
<uses-permission android:name="android.permission.WAKE_LOCK" />
Next, you will need to declare the use of the MediaButtonReceiver
from the Android support library. This will allow you to intercept media control button interactions and headphone events on devices running KitKat and earlier. Add this receiver
inside application
tag:
<receiver android:name="android.support.v4.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
<action android:name="android.media.AUDIO_BECOMING_NOISY" />
</intent-filter>
</receiver>
Now, copy this MediaStyleHelper.java file (which written by Ian Lake, Developer Advocate at Google) to your project to clean up the creation of media style notifications:
MediaStyleHelper.java
package com.example.mediasessioncompat;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaButtonReceiver;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.support.v7.app.NotificationCompat;
/**
* Helper APIs for constructing MediaStyle notifications
*/
public class MediaStyleHelper {
/**
* Build a notification using the information from the given media session. Makes heavy use
* of {@link MediaMetadataCompat#getDescription()} to extract the appropriate information.
* @param context Context used to construct the notification.
* @param mediaSession Media session to get information.
* @return A pre-built notification with information from the given media session.
*/
public static NotificationCompat.Builder from(
Context context, MediaSessionCompat mediaSession) {
MediaControllerCompat controller = mediaSession.getController();
MediaMetadataCompat mediaMetadata = controller.getMetadata();
MediaDescriptionCompat description = mediaMetadata.getDescription();
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
builder
.setContentTitle(description.getTitle())
.setContentText(description.getSubtitle())
.setSubText(description.getDescription())
.setLargeIcon(description.getIconBitmap())
.setContentIntent(controller.getSessionActivity())
.setDeleteIntent(
MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
return builder;
}
}
Creating the background Audio Service
MediaBrowserServiceCompat
(I named it is BackgroundAudioService
) and implements MediaPlayer.OnCompletionListener
and AudioManager.OnAudioFocusChangeListener
interfaces. Firstly, override onGetRoot()
and onLoadChildren()
with the default code (do nothing):
@Nullable
@Override
public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
if(TextUtils.equals(clientPackageName, getPackageName())) {
return new BrowserRoot(getString(R.string.app_name), null);
}
return null;
}
@Override
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
result.sendResult(null);
}
onStartCommand()
method, which is the entry point into your Service
. This method will take the Intent
that is passed to the Service
and send it to the MediaButtonReceiver
class:
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
MediaButtonReceiver.handleIntent(mediaSessionCompat, intent);
return super.onStartCommand(intent, flags, startId);
}
In addition, you should have a BroadcastReceiver
that listens for changes in the headphone state. To keep things simple, this receiver will pause the MediaPlayer
if it is playing:
private BroadcastReceiver headPhoneReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if( mediaPlayer != null && mediaPlayer.isPlaying() ) {
mediaPlayer.pause();
}
}
};
In onCreate()
method of this class, initializing a MediaPlayer
, a MediaSessionCompat
and register the BroadcastReceiver
above:
private MediaPlayer mediaPlayer;
private MediaSessionCompat mediaSessionCompat;
@Override
public void onCreate() {
super.onCreate();
initMediaPlayer();
initMediaSession();
initNoisyReceiver();
}
private void initMediaPlayer() {
mediaPlayer = new MediaPlayer();
mediaPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC);
mediaPlayer.setVolume(1.0f, 1.0f);
}
private void initMediaSession() {
ComponentName mediaButtonReceiver = new ComponentName(getApplicationContext(), MediaButtonReceiver.class);
mediaSessionCompat = new MediaSessionCompat(getApplicationContext(), "Tag", mediaButtonReceiver, null);
mediaSessionCompat.setCallback(mediaSessionCallback);
mediaSessionCompat.setFlags( MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS );
Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
mediaButtonIntent.setClass(this, MediaButtonReceiver.class);
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, mediaButtonIntent, 0);
mediaSessionCompat.setMediaButtonReceiver(pendingIntent);
setSessionToken(mediaSessionCompat.getSessionToken());
}
private void initNoisyReceiver() {
//Handles headphones coming unplugged. cannot be done through a manifest receiver
IntentFilter filter = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
registerReceiver(headPhoneReceiver, filter);
}
Now, it's time to look into handling audio focus. Overriding onAudioFocusChange()
method with following code:
@Override
public void onAudioFocusChange(int focusChange) {
switch( focusChange ) {
case AudioManager.AUDIOFOCUS_LOSS: {
if( mediaPlayer.isPlaying() ) {
mediaPlayer.stop();
}
break;
}
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: {
mediaPlayer.pause();
break;
}
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: {
if( mediaPlayer != null ) {
mediaPlayer.setVolume(0.3f, 0.3f);
}
break;
}
case AudioManager.AUDIOFOCUS_GAIN: {
if( mediaPlayer != null ) {
if( !mediaPlayer.isPlaying() ) {
mediaPlayer.start();
}
mediaPlayer.setVolume(1.0f, 1.0f);
}
break;
}
}
}
This is explanation about some common states of volume control (in AudioManager
class):
AUDIOFOCUS_LOSS
: used to indicate a loss of audio focus of unknown duration. This occurs when another app has requested audio focus. When this happens, you should stop audio playback in your app.AUDIOFOCUS_LOSS_TRANSIENT
: used to indicate a transient loss of audio focus. This state is entered when another app wants to play audio, but it only anticipates needing focus for a short time. You can use this state to pause your audio playback.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
: when audio focus is requested, but throws a 'can duck' state, it means that you can continue your playback, but should bring the volume down a bit. This can occur when a notification sound is played by the device.AUDIOFOCUS_GAIN
: this is the state when a duckable audio playback has completed, and your app can resume at its previous levels.
MediaSessionCompat.Callback
variable (you've set this callback to mediaSessionCompat
through setCallback()
method in onCreate()
of this class). You must override 3 methods: onPlay()
, onPause
and onPlayFromMediaId()
. This is the code:
private MediaSessionCompat.Callback mediaSessionCallback = new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
super.onPlay();
if( !successfullyRetrievedAudioFocus() ) {
return;
}
mediaSessionCompat.setActive(true);
setMediaPlaybackState(PlaybackStateCompat.STATE_PLAYING);
showPlayingNotification();
mediaPlayer.start();
}
@Override
public void onPause() {
super.onPause();
if( mediaPlayer.isPlaying() ) {
mediaPlayer.pause();
setMediaPlaybackState(PlaybackStateCompat.STATE_PAUSED);
showPausedNotification();
}
}
@Override
public void onPlayFromMediaId(String mediaId, Bundle extras) {
super.onPlayFromMediaId(mediaId, extras);
try {
AssetFileDescriptor afd = getResources().openRawResourceFd(Integer.valueOf(mediaId));
if (afd == null) {
return;
}
try {
mediaPlayer.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
} catch (IllegalStateException e) {
mediaPlayer.release();
initMediaPlayer();
mediaPlayer.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
}
afd.close();
initMediaSessionMetadata();
} catch (IOException e) {
return;
}
try {
mediaPlayer.prepare();
} catch (IOException e) {
e.printStackTrace();
}
}
};
private void showPlayingNotification() {
NotificationCompat.Builder builder = MediaStyleHelper.from(BackgroundAudioService.this, mediaSessionCompat);
if( builder == null ) {
return;
}
builder.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_pause, "Pause", MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)));
builder.setStyle(new NotificationCompat.MediaStyle().setShowActionsInCompactView(0).setMediaSession(mediaSessionCompat.getSessionToken()));
builder.setSmallIcon(R.mipmap.ic_launcher);
NotificationManagerCompat.from(BackgroundAudioService.this).notify(1, builder.build());
}
private void showPausedNotification() {
NotificationCompat.Builder builder = MediaStyleHelper.from(this, mediaSessionCompat);
if( builder == null ) {
return;
}
builder.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "Play", MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)));
builder.setStyle(new NotificationCompat.MediaStyle().setShowActionsInCompactView(0).setMediaSession(mediaSessionCompat.getSessionToken()));
builder.setSmallIcon(R.mipmap.ic_launcher);
NotificationManagerCompat.from(this).notify(1, builder.build());
}
private void setMediaPlaybackState(int state) {
PlaybackStateCompat.Builder playbackstateBuilder = new PlaybackStateCompat.Builder();
if( state == PlaybackStateCompat.STATE_PLAYING ) {
playbackstateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_PAUSE);
} else {
playbackstateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_PLAY);
}
playbackstateBuilder.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 0);
mediaSessionCompat.setPlaybackState(playbackstateBuilder.build());
}
private void initMediaSessionMetadata() {
MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder();
//Notification icon in card
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher));
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher));
//lock screen icon for pre lollipop
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher));
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, "Beo Dat May Troi");
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, "Singer: Anh Tho");
metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, 1);
metadataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, 1);
mediaSessionCompat.setMetadata(metadataBuilder.build());
}
private boolean successfullyRetrievedAudioFocus() {
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
int result = audioManager.requestAudioFocus(this,
AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
return result == AudioManager.AUDIOFOCUS_GAIN;
}
As you can see on the code above, in the onPlay()
, you must check if audio focus was granted first. Below the conditional statement, you will want to set the MediaSessionCompat
object to active, give it a state of STATE_PLAYING
, and assign the proper actions necessary to create pause buttons on pre-Lollipop lock screen controls. setMediaPlaybackState()
method will be called to builds and associates a PlaybackStateCompat
with your MediaSessionCompat
object.
Moreover, you must show a playing notification that is associated with your
MediaSessionCompat
object by using the MediaStyleHelper
class that we defined earlier, and then show that notification by call showPlayingNotification()
method.Finally, you will start the
MediaPlayer
at the end of onPlay()
. When user click "paused button" button at the notification area,
onPause()
will be called. Here you will pause the MediaPlayer
, set the state to STATE_PAUSED
and show a paused notification through call showPausedNotification()
.The next method in the callback is
onPlayFromMediaId()
, takes a String
and a Bundle
as parameters. This is the callback method that you can use for changing audio tracks/content within your app.
In this tutorial, we will simply accept a raw resource ID (a mp3 file) and attempt to play it.There are some optional methods you can override are:
onSeekTo()
: allows you to change the playback position of your content.onCommand()
: allow you to send custom commands to yourService
.
MediaPlayer
:
@Override
public void onCompletion(MediaPlayer mediaPlayer) {
if( mMediaPlayer != null ) {
mMediaPlayer.release();
}
}
Unregister the BroadcastReceiver
, cancel the notification and release MediaSessionCompat
in onDestroy()
:
@Override
public void onDestroy() {
super.onDestroy();
AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
audioManager.abandonAudioFocus(this);
unregisterReceiver(mNoisyReceiver);
mMediaSessionCompat.release();
NotificationManagerCompat.from(this).cancel(1);
}
And never forget to register this Service
in your AndroidManifest.xml:
<service android:name=".BackgroundAudioService">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
<action android:name="android.media.AUDIO_BECOMING_NOISY" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
Creating an Activity to control the media player
Activity
. In this activity, you should have a MediaBrowserCompat.ConnectionCallback
, MediaControllerCompat.Callback
, MediaBrowserCompat
and MediaControllerCompat
objects created in your app. This is the main activity source code:
MainActivity.java
package info.devexchanges.backgroundaudio;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.RemoteException;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.support.v7.app.AppCompatActivity;
import android.view.View;
import android.widget.Button;
public class MainActivity extends AppCompatActivity {
private static final int STATE_PAUSED = 0;
private static final int STATE_PLAYING = 1;
private int currentState;
private MediaBrowserCompat mediaBrowserCompat;
private MediaBrowserCompat.ConnectionCallback connectionCallback = new MediaBrowserCompat.ConnectionCallback() {
@Override
public void onConnected() {
super.onConnected();
try {
MediaControllerCompat mediaControllerCompat = new MediaControllerCompat(MainActivity.this, mediaBrowserCompat.getSessionToken());
mediaControllerCompat.registerCallback(mediaControllerCompatCallback);
setSupportMediaController(mediaControllerCompat);
getSupportMediaController().getTransportControls().playFromMediaId(String.valueOf(R.raw.beo_dat_may_troi__anh_tho), null);
} catch( RemoteException e ) {
e.printStackTrace();
}
}
};
private MediaControllerCompat.Callback mediaControllerCompatCallback = new MediaControllerCompat.Callback() {
@Override
public void onPlaybackStateChanged(PlaybackStateCompat state) {
super.onPlaybackStateChanged(state);
if( state == null ) {
return;
}
switch( state.getState() ) {
case PlaybackStateCompat.STATE_PLAYING: {
currentState = STATE_PLAYING;
break;
}
case PlaybackStateCompat.STATE_PAUSED: {
currentState = STATE_PAUSED;
break;
}
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Button btnPlay = (Button) findViewById(R.id.btn_play);
mediaBrowserCompat = new MediaBrowserCompat(this, new ComponentName(this, BackgroundAudioService.class),
connectionCallback, getIntent().getExtras());
mediaBrowserCompat.connect();
btnPlay.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if( currentState == STATE_PAUSED ) {
getSupportMediaController().getTransportControls().play();
currentState = STATE_PLAYING;
} else {
if( getSupportMediaController().getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) {
getSupportMediaController().getTransportControls().pause();
}
currentState = STATE_PAUSED;
}
}
});
}
@Override
protected void onDestroy() {
super.onDestroy();
if( getSupportMediaController().getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) {
getSupportMediaController().getTransportControls().pause();
}
mediaBrowserCompat.disconnect();
}
}
MediaControllerCompat.Callback
will have a method called onPlaybackStateChanged()
that receives changes in playback state, and can be used to keep your UI in sync.MediaBrowserCompat.ConnectionCallback
has onConnected()
method that will be called when a new MediaBrowserCompat
object is created and connected. You can use this to initialize your MediaControllerCompat
object, link it to your MediaControllerCompat.Callback
, and associate it with MediaSessionCompat
from your Service
. Once that is completed, you can start audio playback from this method.Finally, when your
Activity
destroyed, you should pause the audio service and disconnect your MediaBrowserCompat
object.And this is the activity layout (XML file):
activity_main.xml
Running this application, you will see this layout on the notification area while audio is playing on background:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin">
<Button
android:id="@+id/btn_play"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="Play/Pause Audio" />
</RelativeLayout>
And if you lock device screen, you still be able to control playback from your app with this "lock screen controls":
Conclusions
References
- References to original post on Code.Tutplus.Com (written by Paul Trebilcox-Ruiz)
- Managing Audio playback on Google developer guide.