⬆️ ⬇️

Android and sound: how to do it right

This article discusses the architecture and API for building music-playing applications. We will write a simple application that will play a small predetermined playlist, but “in an adult way” - using officially recommended practices. We will use MediaSession and MediaController to organize a single access point to the media player, and MediaBrowserService to support Android Auto. And also we will stipulate a number of steps which are obligatory if we do not want to cause hatred of the user.



In the first approximation, the task looks simple: in the activity we create the MediaPlayer, when you click the Play button, we start playback, and Stop - we stop it. Everything works fine until the user exits the activity. The obvious solution would be to transfer MediaPlayer to the service. However, now we have questions of organizing access to the player from the UI. We will have to implement a binded service, come up with an API for it that would allow us to control the player and receive events from it. But this is only half the battle: no one except us knows the service API, respectively, our activity will be the only means of control. The user will have to go into the application and click Pause if he wants to call. Ideally, we need a unified way to tell Android that our application is a player, it can be controlled, and that we are currently playing such and such a track from such and such an album. So that the system on its part helps us with UI. Lollipop (API 21) introduced such a mechanism in the form of MediaSession and MediaController classes. A little later in the support library appeared their twins MediaSessionCompat and MediaControllerCompat.



It should be immediately noted that the MediaSession is not related to sound reproduction, it is only about managing the player and its metadata.



Media session



So, we create an instance of MediaSession in the service, fill it with information about our player, its status and give MediaSession.Callback , which defines the methods onPlay, onPause, onStop, onSkipToNext and others. In these methods we put the control code MediaPlayer (in the example we will use ExoPlayer ). Our goal is that the events from the hardware buttons, and from the lock window, and from the clock under Android Wear cause these methods.



Fully working code is available on GitHub (master branch). The articles are only recycled excerpts from it.



//   // ...  final MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder(); // ...  //    ,     . // ,     ACTION_PAUSE, //       onPause. // ACTION_PLAY_PAUSE ,     //   Android Wear! final PlaybackStateCompat.Builder stateBuilder = new PlaybackStateCompat.Builder() .setActions( PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_STOP | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_PLAY_PAUSE | PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS); MediaSessionCompat mediaSession; @Override public void onCreate() { super.onCreate(); // "PlayerService" -  tag   mediaSession = new MediaSessionCompat(this, "PlayerService"); // FLAG_HANDLES_MEDIA_BUTTONS -       // (, ) // FLAG_HANDLES_TRANSPORT_CONTROLS -      //    mediaSession.setFlags( MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); //    mediaSession.setCallback(mediaSessionCallback); Context appContext = getApplicationContext() //  activity,   ,   //     Intent activityIntent = new Intent(appContext, MainActivity.class); mediaSession.setSessionActivity( PendingIntent.getActivity(appContext, 0, activityIntent, 0)); } @Override public void onDestroy() { super.onDestroy(); //    mediaSession.release(); } MediaSessionCompat.Callback mediaSessionCallback = new MediaSessionCompat.Callback() { @Override public void onPlay() { MusicRepository.Track track = musicRepository.getCurrent(); //     MediaMetadataCompat metadata = metadataBuilder .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, BitmapFactory.decodeResource(getResources(), track.getBitmapResId())); .putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.getTitle()); .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, track.getArtist()); .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, track.getArtist()); .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, track.getDuration()) .build(); mediaSession.setMetadata(metadata); // ,         //        mediaSession.setActive(true); //    mediaSession.setPlaybackState( stateBuilder.setState(PlaybackStateCompat.STATE_PLAYING, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1).build()); //  URL -  ExoPlayer prepareToPlay(track.getUri()); //   exoPlayer.setPlayWhenReady(true); } @Override public void onPause() { //   exoPlayer.setPlayWhenReady(false); //    mediaSession.setPlaybackState( stateBuilder.setState(PlaybackStateCompat.STATE_PAUSED, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1).build()); } @Override public void onStop() { //   exoPlayer.setPlayWhenReady(false); // ,    "" ,    mediaSession.setActive(false); //    mediaSession.setPlaybackState( stateBuilder.setState(PlaybackStateCompat.STATE_STOPPED, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1).build()); } } 


A token is required for external access to the MediaSession. To do this, teach the service to give it.



 @Override public IBinder onBind(Intent intent) { return new PlayerServiceBinder(); } public class PlayerServiceBinder extends Binder { public MediaSessionCompat.Token getMediaSessionToken() { return mediaSession.getSessionToken(); } } 


and write in the manifest



 <service android:name=".service.PlayerService" android:exported="false"> </service> 


MediaController



Now let's implement the activity with control buttons. We create an instance of MediaController and pass to the constructor the token received from the service.



MediaController provides both play, pause, stop player control methods, and onPlaybackStateChanged (PlaybackState state) and onMetadataChanged (MediaMetadata metadata) callbacks. Multiple MediaControllers can connect to the same MediaSession, so you can easily ensure the consistency of button states in all windows.



 PlayerService.PlayerServiceBinder playerServiceBinder; MediaControllerCompat mediaController; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); final Button playButton = (Button) findViewById(R.id.play); final Button pauseButton = (Button) findViewById(R.id.pause); final Button stopButton = (Button) findViewById(R.id.stop); bindService(new Intent(this, PlayerService.class), new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder service) { playerServiceBinder = (PlayerService.PlayerServiceBinder) service; try { mediaController = new MediaControllerCompat( MainActivity.this, playerServiceBinder.getMediaSessionToken()); mediaController.registerCallback( new MediaControllerCompat.Callback() { @Override public void onPlaybackStateChanged(PlaybackStateCompat state) { if (state == null) return; boolean playing = state.getState() == PlaybackStateCompat.STATE_PLAYING; playButton.setEnabled(!playing); pauseButton.setEnabled(playing); stopButton.setEnabled(playing); } } ); } catch (RemoteException e) { mediaController = null; } } @Override public void onServiceDisconnected(ComponentName name) { playerServiceBinder = null; mediaController = null; } }, BIND_AUTO_CREATE); playButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (mediaController != null) mediaController.getTransportControls().play(); } }); pauseButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (mediaController != null) mediaController.getTransportControls().pause(); } }); stopButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (mediaController != null) mediaController.getTransportControls().stop(); } }); } 


Our activity is working, but the idea was originally, so that you could also manage from the blocking window. And here we come to an important point: API 21 completely redesigned the blocking window, now there are displayed notifications and player control buttons must be done through notifications. We will come back to this later, let's look at the old lock window for now.



As soon as we call mediaSession.setActive (true), the system magically joins MediaSession without any tokens and shows control buttons against the background of the image from the metadata.



However, for historical reasons, the events on the press of buttons come not directly to the MediaSession, but in the form of broadcasts. Accordingly, we still need to subscribe to these broadcasts and transfer them to the MediaSession.



MediaButtonReceiver



For this, Android developers kindly offer us to use MediaButtonReceiver, a ready-made receiver.



Add it to the manifest



 <receiver android:name="android.support.v4.media.session.MediaButtonReceiver"> <intent-filter> <action android:name="android.intent.action.MEDIA_BUTTON" /> </intent-filter> </receiver> 


When receiving an event, MediaButtonReceiver looks for a service in the application, which also accepts "android.intent.action.MEDIA_BUTTON" and redirects it there. Therefore, we will add a similar content filter to the service.



 <service android:name=".service.PlayerService" android:exported="false"> <intent-filter> <action android:name="android.intent.action.MEDIA_BUTTON" /> </intent-filter> </service> 


If a suitable service is not found or there are several of them, IllegalStateException will be thrown.



Now add to the service



 @Override public int onStartCommand(Intent intent, int flags, int startId) { MediaButtonReceiver.handleIntent(mediaSession, intent); return super.onStartCommand(intent, flags, startId); } 


The handleIntent method analyzes the button codes from the intent and calls the corresponding callbacks in the mediaSession. It turned out a little dancing with a tambourine, but almost without writing code.



On systems with API> = 21, the system does not use broadcasts to send button press events and instead directly calls MediaSession. However, if our MediaSession is inactive (setActive (false)), it will be awakened by Broadcast. And in order for this mechanism to work, you need to inform the MediaSession which receiver to send broadcasts to.

Add to onCreate service



 Intent mediaButtonIntent = new Intent( Intent.ACTION_MEDIA_BUTTON, null, appContext, MediaButtonReceiver.class); mediaSession.setMediaButtonReceiver( PendingIntent.getBroadcast(appContext, 0, mediaButtonIntent, 0)); 


On systems with an API <21, the setMediaButtonReceiver method does nothing.



OK well. We start, go to the blocking window and ... there is nothing. Because we forgot an important point, without which nothing works, - getting audiofocus.



Audiofocus



There is always the possibility that several applications will want to simultaneously play the sound. Or received an incoming call and urgently need to stop the music. In order to solve these problems, the AudioManager system service included the ability to request audio focus. Audiofocus is the right to play the sound and is issued only to one application at a time. If the application is denied the provision of audiofocus or was taken away later, the sound should be stopped. As a rule, focus is always provided, that is, when an application is pressed to play, all other applications are silent. An exception happens only during an active telephone conversation. Technically, no one forces us to receive focus, but we do not want to annoy the user? Well, plus the blocking window ignores applications without audiofocus.

Focus must be requested in onPlay () and released in onStop ().



Get AudioManager in onCreate



 @Override public void onCreate() { super.onCreate(); audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); ... } 


We request focus on onPlay



 @Override public void onPlay() { ... int audioFocusResult = audioManager.requestAudioFocus( audioFocusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); if (audioFocusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) return; //       setActive! mediaSession.setActive(true); ... } 


And free in onStop



 @Override public void onStop() { ... audioManager.abandonAudioFocus(audioFocusChangeListener); ... } 


When requesting focus, we gave a callback.



 private AudioManager.OnAudioFocusChangeListener audioFocusChangeListener = new AudioManager.OnAudioFocusChangeListener() { @Override public void onAudioFocusChange(int focusChange) { switch (focusChange) { case AudioManager.AUDIOFOCUS_GAIN: //  . // ,        . //  ,    //    . mediaSessionCallback.onPlay(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: //  ,   -   //  "". // ,       // " 50   ". //        , //    . //      , //    ,     . mediaSessionCallback.onPause(); break; default: //   . mediaSessionCallback.onPause(); break; } } }; 


That's it, now the lockout window on systems with API <21 is working.



So it looks

Android 4.4

Android 4.4



MIUI 8 (based on Android 6, that is, theoretically, the lock window should not display our track, but here the customization of MIUI already affects).

MIUI 8



Notifications



However, as previously mentioned, starting from API 21, the blocking window learned how to display notifications. And on this happy occasion, the above mechanism was cut out. So now let's still form notifications. This is not only a requirement of modern systems, but simply convenient, since the user does not have to turn the screen off and on just to pause. At the same time apply this notification to transfer the service in the foreground-mode.



We do not have to draw custom notification, since Android provides a special style for players - Notification.MediaStyle .



Add to the service two methods



 void refreshNotificationAndForegroundStatus(int playbackState) { switch (playbackState) { case PlaybackStateCompat.STATE_PLAYING: { startForeground(NOTIFICATION_ID, getNotification(playbackState)); break; } case PlaybackStateCompat.STATE_PAUSED: { //      foreground,   , //    play  NotificationManagerCompat.from(PlayerService.this) .notify(NOTIFICATION_ID, getNotification(playbackState)); stopForeground(false); break; } default: { // ,    stopForeground(true); break; } } } Notification getNotification(int playbackState) { // MediaStyleHelper    . //    Ian Lake / Android Framework Developer at Google //   : https://gist.github.com/ianhanniballake/47617ec3488e0257325c NotificationCompat.Builder builder = MediaStyleHelper.from(this, mediaSession); //   // ...   builder.addAction( new NotificationCompat.Action( android.R.drawable.ic_media_previous, getString(R.string.previous), MediaButtonReceiver.buildMediaButtonPendingIntent( this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS))); // ...play/pause if (playbackState == PlaybackStateCompat.STATE_PLAYING) builder.addAction( new NotificationCompat.Action( android.R.drawable.ic_media_pause, getString(R.string.pause), MediaButtonReceiver.buildMediaButtonPendingIntent( this, PlaybackStateCompat.ACTION_PLAY_PAUSE))); else builder.addAction( new NotificationCompat.Action( android.R.drawable.ic_media_play, getString(R.string.play), MediaButtonReceiver.buildMediaButtonPendingIntent( this, PlaybackStateCompat.ACTION_PLAY_PAUSE))); // ...   builder.addAction( new NotificationCompat.Action(android.R.drawable.ic_media_next, getString(R.string.next), MediaButtonReceiver.buildMediaButtonPendingIntent( this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT))); builder.setStyle(new NotificationCompat.MediaStyle() //     Action    . //     play/pause. .setShowActionsInCompactView(1) //        . //    ,   API < 21 -    //      foreground- //    stopForeground(false). //    . //  API >= 21   ,    . .setShowCancelButton(true) // ,         .setCancelButtonIntent( MediaButtonReceiver.buildMediaButtonPendingIntent( this, PlaybackStateCompat.ACTION_STOP)) //  .    Android Wear.    , //   Android Wear  ,      .setMediaSession(mediaSession.getSessionToken())); builder.setSmallIcon(R.mipmap.ic_launcher); builder.setColor(ContextCompat.getColor(this, R.color.colorPrimaryDark)); //     .        builder.setShowWhen(false); //  .        Android Wear //      . builder.setPriority(NotificationCompat.PRIORITY_HIGH); //         builder.setOnlyAlertOnce(true); return builder.build(); } 


And add the refreshNotificationAndForegroundStatus (int playbackState) call to all MediaSession callbacks.



So it looks

Android 4.4

Android 4.4



Android 7.1.1

Android 7.1.1



Android Wear

Android Wear



Started service



In principle, we already have everything working, but there is an ambush: our activity starts the service through binding. Accordingly, after the activity has disengaged from the service, it will be destroyed and the music will stop. Therefore, we need to add to onPlay



 startService(new Intent(getApplicationContext(), PlayerService.class)); 


No processing in onStartCommand is necessary, our goal is not to let the system kill the service after onUnbind.



And in onStop add



 stopSelf(); 


In case clients are attached to the service, stopSelf does nothing, it just cocks the flag that after onUnbind the service can be destroyed. So it is completely safe.



ACTION_AUDIO_BECOMING_NOISY



We continue to polish the service. Suppose a user listens to music on headphones and pulls them out. If this situation is not specially treated, the sound will switch to the speaker of the phone and everyone around will hear it. It would be good in this case to pause.

For this Android has a special Broadcast AudioManager.ACTION_AUDIO_BECOMING_NOISY.

Add to onPlay



 registerReceiver( becomingNoisyReceiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); 


In onPause and onStop



 unregisterReceiver(becomingNoisyReceiver); 


And in fact the events pause



 final BroadcastReceiver becomingNoisyReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { mediaSessionCallback.onPause(); } } }; 


Android Auto



Starting from API 21, it became possible to integrate a phone with a screen in a car. To do this, you need to install the Android Auto application and connect the phone to a compatible car. Large controls will be displayed on the car’s screen to control navigation, messages and music. Let's offer Android Auto our application as a music provider.



If you don’t have a compatible car at hand, you will sometimes agree that you can simply launch the application and the screen of the phone itself will work as a car.



The source code is posted on GitHub (MediaBrowserService branch).



First of all, you need to specify in the manifest that our application is compatible with Android Auto.

Add to manifest



 <meta-data android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/> 


Here, automotive_app_desc is the link to the automotive_app_desc.xml file that you need to create in the xml folder



 <automotiveApp> <uses name="media" /> </automotiveApp> 


Let's transform our service to MediaBrowserService . His task, in addition to everything previously done, is to give the token to Android Auto and provide playlists.



Let's correct service declaration in the manifest



 <service android:name=".service.PlayerService" android:exported="true" tools:ignore="ExportedService" > <intent-filter> <action android:name="android.media.browse.MediaBrowserService"/> <action android:name="android.intent.action.MEDIA_BUTTON" /> </intent-filter> </service> 


First, now our service is exported, since it will be connected to it outside.



And, secondly, the intent filter android.media.browse.MediaBrowserService is added.



Change parent class to MediaBrowserServiceCompat.



Since now the service should give different IBinder depending on the intensity, we fix onBind



 @Override public IBinder onBind(Intent intent) { if (SERVICE_INTERFACE.equals(intent.getAction())) { return super.onBind(intent); } return new PlayerServiceBinder(); } 


Implement two abstract methods that return playlists.



 @Override public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) { //    rootId -    "Root". //  RootId ,     //  onLoadChildren  parentId. //    ,     clientPackageName  //    ,    ,   //  . //         , //   return null; return new BrowserRoot("Root", null); } @Override public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) { //  .    FLAG_PLAYABLE //  FLAG_BROWSABLE. //  FLAG_PLAYABLE    , //  FLAG_BROWSABLE    ,   //    ,   onLoadChildren  parentId //  browsable-. //        , //     . ArrayList<MediaBrowserCompat.MediaItem> data = new ArrayList<>(musicRepository.getTrackCount()); MediaDescriptionCompat.Builder descriptionBuilder = new MediaDescriptionCompat.Builder(); for (int i = 0; i < musicRepository.getTrackCount() - 1; i++) { MusicRepository.Track track = musicRepository.getTrackByIndex(i); MediaDescriptionCompat description = descriptionBuilder .setDescription(track.getArtist()) .setTitle(track.getTitle()) .setSubtitle(track.getArtist()) //     Uri //.setIconBitmap(...) .setIconUri(new Uri.Builder() .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) .authority(getResources() .getResourcePackageName(track.getBitmapResId())) .appendPath(getResources() .getResourceTypeName(track.getBitmapResId())) .appendPath(getResources() .getResourceEntryName(track.getBitmapResId())) .build()) .setMediaId(Integer.toString(i)) .build(); data.add(new MediaBrowserCompat.MediaItem(description, FLAG_PLAYABLE)); } result.sendResult(data); } 


And finally, we are implementing a new callback MediaSession



 @Override public void onPlayFromMediaId(String mediaId, Bundle extras) { playTrack(musicRepository.getTrackByIndex(Integer.parseInt(mediaId))); } 


Here mediaId is the one that we gave to setMediaId in onLoadChildren.



So it looks

Playlist

Playlist



Track

Track



UPDATE from 10/27/2017: GitHub example transferred to targetSdkVersion = 26. From the changes relevant to the topic of the article, the following should be noted:





')

Source: https://habr.com/ru/post/339416/



All Articles