Chapters

Hide chapters

Android Apprentice

Third Edition · Android 10 · Kotlin 1.3 · Android Studio 3.6

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

26. Podcast Playback
Written by Tom Blankenship

At this point, you’ve built a decent podcast management app, but there’s no way to listen to content. Time to fix that!

In this chapter, you’ll learn how to build a media player that plays audio and video podcasts, and integrate it into the Android ecosystem. Building a good media player takes some work. The payoff, however, is an app that works well in the foreground and also while the user performs other tasks on their device.

Getting started

If you’re following along with your own project, the starter project for this chapter includes an additional icon that you’ll need to complete the section. Open your project then copy the following resources from the provided starter project into yours:

  • src/main/res/drawable/ic_pause_white.png
  • src/main/res/drawable/ic_play_arrow_white.png
  • src/main/res/drawable/ic_episode_icon.png

Also, copy all of the files from the drawable folders, including folders with the -hdpi, -mdpi, -xhdpi, -xxhdpi and -xxxhdpi extensions.

If you don’t have your own project, don’t worry. Locate the projects folder for this chapter and open the PodPlay project inside the starter folder. The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.

Media player basics

Note: The Media classes mentioned here have backward compatible versions that you’ll use when building the app. The Compat part of the class names have been left out for brevity (i.e., MediaPlayer = MediaPlayerCompat).

The architecture for an app that requires media playback can be confusing. Getting a birds-eye view of how it works is often the best place to start. As daunting as this diagram might be, it’s meant to show you that adding media playback to an Android app requires two large pieces: the playback UI (PlayerFragment) and the playback service (MediaBrowserService).

MediaPlayer

The built-in core tool that Android provides for media playback is MediaPlayer. This class handles both audio and video and can play content stored locally or streamed from an external URL. MediaPlayer has standard calls for loading media, starting playback, pausing playback and seeking to a playback position.

MediaSession

Android provides another class named MediaSession that is designed to work with any media player, either the built-in MediaPlayer or one of your choosing. The MediaSession provides callbacks for onPlay(), onPause() and onStop() that you’ll use to create and control the media player.

One significant advantage of using a MediaSession is that systems other than your app can access it.

MediaController

The MediaController is used directly by the user interface, which in turn, communicates with a MediaSession, isolating your UI code from the MediaSession. MediaController provides callbacks for major MediaSession events, which you can use to update your UI.

MediaBrowserService

For a better listening experience, you’ll let the podcast play in the background and give the user playback controls from outside of PodPlay. There are many ways a user may want to control audio from outside an app, and MediaBrowserService makes it possible.

MediaBrowserService runs as a foreground service when playing audio. When a service is running in foreground mode, Android makes sure it sticks around.

With other background services, Android tends to kill them off — which isn’t something you want when the user is listening to a long-running podcast.

One central feature of MediaBrowserService is that it’s discoverable and other apps can use it to playback your media, which allows advanced features, such as playback from Android Wear or Android Auto devices.

MediaBrowser

To control the MediaBrowserService service, you’ll use MediaBrowser. This class connects to the MediaBrowserService service and provides it with a MediaController. Your UI will then use a MediaController to control the playback operations. Other apps can also use their own MediaBrowser to connect to the PodPlay MediaBrowserService.

Building the MediaBrowserService

MediaBrowserService is where all of the hard work of managing the podcast playback happens. You’ll start with a basic implementation that’s just enough to get a podcast playing and then expand the service later.

In the app’s build.gradle, add the following dependency:

implementation "androidx.media:media:1.1.0"

Click make project.

Inside service, create a new file and name it PodplayMediaService.kt. Replace its contents with the following:

import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import androidx.media.MediaBrowserServiceCompat

class PodplayMediaService : MediaBrowserServiceCompat() {

  override fun onCreate() {
    super.onCreate()
  }

  override fun onLoadChildren(parentId: String,
      result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
    // To be implemented
  }

  override fun onGetRoot(clientPackageName: String,
      clientUid: Int, rootHints: Bundle?): BrowserRoot? {
    // To be implemented
    return null
  }
}

This represents the basic outline of a MediaBrowserServiceCompat class with overloaded methods for onLoadChildren() and onGetRoot(). You’ll come back to these methods later in the chapter.

Similar to other services, PodplayMediaService needs an entry in the manifest.

Open AndroidManifest.xml and add the following below the main <application> section:

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

This allows a MediaBrowser to find your media browser service.

Create a MediaSession

At the heart of MediaBrowserService is MediaSession. As PodPlay and other apps interact through MediaBrowserService, MediaSession responds. But before it can, you need to create the MediaSession when the service first starts.

Open PodplayMediaService.kt and add the following property:

private lateinit var mediaSession: MediaSessionCompat

Now, add the following method:

private fun createMediaSession() {
  // 1
  mediaSession = MediaSessionCompat(this, "PodplayMediaService")
  // 2
  setSessionToken(mediaSession.sessionToken)
  // 3
  // Assign Callback
}

Let’s walk through the code:

  1. The mediaSession property is initialized with a new MediaSessionCompat object.

  2. The unique token for the media session is retrieved and applied as the session token on the PodplayMediaService, which links the service to the media session.

  3. The only missing part is assigning a Callback class to the media session. You’ll create this next.

To finish out the initialization of the media session, you need to define a MediaSessionCompat.Callback to handle media events.

Inside service, create a new file and name it PodplayMediaCallback.kt. Replace its contents with the following:

class PodplayMediaCallback(val context: Context,
                           val mediaSession: MediaSessionCompat,
                           var mediaPlayer: MediaPlayer? = null) :
    MediaSessionCompat.Callback() {

  override fun onPlayFromUri(uri: Uri?, extras: Bundle?) {
    super.onPlayFromUri(uri, extras)
    println("Playing ${uri.toString()}")
    onPlay()
  }

  override fun onPlay() {
    super.onPlay()
    println("onPlay called")
  }

  override fun onStop() {
    super.onStop()
    println("onStop called")
  }

  override fun onPause() {
    super.onPause()
    println("onPause called")
  }
}

This is the skeleton code for the Callback; it doesn’t do anything yet. Although you can handle other events, these are sufficient for PodPlay.

You’ll come back to this later and fill in the details of each callback method. In the meantime, you can finish out the media session initialization.

In PodplayMediaService.kt, add the following to the end of createMediaSession():

val callBack = PodplayMediaCallback(this, mediaSession)
mediaSession.setCallback(callBack)

This creates a new instance of PodplayMediaCallback and sets it as the media session callback.

Add the following to the end of onCreate():

createMediaSession()

Before diving into the detailed implementation of PodplayMediaService, you’ll connect a MediaBrowser to the service and test the communication between the browser and service.

Connecting the MediaBrowser

There’s no podcast episode player UI in the app yet — which is where you’d typically create the MediaBrowser and connect it to the PodplayMediaService — so for now, you’ll add the MediaBrowser code to the podcast details screen instead.

There are four steps to complete when adding MediaBrowser capabilities to an Activity or Fragment:

  1. Create the MediaBrowser object and connect it to the MediaBrowserService.
  2. Define a MediaBrowser.ConnectionCallback to handle the browser service connection messages.
  3. Define a MediaController.Callback class to handle data and state changes from the browser service.
  4. Connect and disconnect the MediaBrowser based on lifecycle events.

Create callbacks

Before adding the MediaBrowser object, you need to define the callback classes.

First, create the MediaController.Callback class. This class will receive messages when the playback state changes and is where you’d typically update your player UI to reflect the current state.

Open PodcastDetailsFragment.kt and add the following inner class:

inner class MediaControllerCallback: MediaControllerCompat.Callback() {
  override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
    super.onMetadataChanged(metadata)
    println(
    "metadata changed to ${metadata?.getString(
        MediaMetadataCompat.METADATA_KEY_MEDIA_URI)}")
  }
  
  override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
    super.onPlaybackStateChanged(state)
    println("state changed to $state")
  }
}

You haven’t implemented a playback UI yet, so the callback methods only print information for now.

Next, create the MediaBrowser.ConnectionCallback class. This requires a MediaControllerCallback object and a MediaBrowser object.

Add the following properties to the top of the PodcastDetailsFragment class:

private lateinit var mediaBrowser: MediaBrowserCompat
private var mediaControllerCallback: MediaControllerCallback? = null

Add the following method:

private fun registerMediaController(token: MediaSessionCompat.Token) {
  // 1
  val fragmentActivity = activity as FragmentActivity
  // 2
  val mediaController = MediaControllerCompat(fragmentActivity, token)
  // 3
  MediaControllerCompat.setMediaController(fragmentActivity, mediaController)
  // 4
  mediaControllerCallback = MediaControllerCallback()
  mediaController.registerCallback(mediaControllerCallback!!)
}

Here’s what’s happening:

  1. You assign a local fragmentActivity to activity since activity is a property that can change to null between calls.

  2. Create the MediaController and associate it with the session token from the MediaSession object. This connects the media controller with the media session.

    Note: Don’t confuse this MediaController class with the one from the Android widget library. The MediaController widget is designed to provide a basic UI for media playback controls. This MediaController is part of the Android media session package, and it used to communicate with an active media session.

  3. Assign the MediaController to the Activity so that you can retrieve it later with getMediaController().

  4. Create a new instance of MediaControllerCallback and set it as the callback object for the media controller.

Add the following inner class:

inner class MediaBrowserCallBacks:
  MediaBrowserCompat.ConnectionCallback() {
    // 1
  override fun onConnected() {
    super.onConnected()
    // 2
    registerMediaController(mediaBrowser.sessionToken)
    println("onConnected")
  }

  override fun onConnectionSuspended() {
    super.onConnectionSuspended()
    println("onConnectionSuspended")
    // Disable transport controls
  }

  override fun onConnectionFailed() {
    super.onConnectionFailed()
    println("onConnectionFailed")
    // Fatal error handling
  }
}

When you create the media browser object, an instance of MediaBrowserCallBacks is passed to the constructor. The MediaBrowserService eventually calls onConnected() upon successful connection to the MediaBrowserService, or it calls onConnectionFailed() if there’s an issue.

  1. onConnected() is called after a successful connection. This is your chance to assign a MediaController controller to the activity, and to register the MediaControllerCallback class with the mediaController.
  2. The MediaController is registered.

Initialize the MediaBrowser

With the two callback classes created, you’re ready to create the media browser object. This asynchronously kicks off the connection to the browser service.

Add the following method:

private fun initMediaBrowser() {
  val fragmentActivity = activity as FragmentActivity
  mediaBrowser = MediaBrowserCompat(fragmentActivity,
      ComponentName(fragmentActivity, 
          PodplayMediaService::class.java),
          MediaBrowserCallBacks(),
          null)
}

Here, you instantiate a new MediaBrowserCompat object using the following arguments:

  1. context: The current activity hosting the fragment.
  2. serviceComponent: This tells the media browser that it should connect to the PodplayMediaService service.
  3. callback: The callback object to receive connection events.
  4. rootHints: Optional service specific hints to pass along as a Bundle object.

Now you can call this method when the Fragment is created. Add the following line to the end of onCreate():

initMediaBrowser()

The final step is to connect the media browser and unregister the media controller at the appropriate times.

Connect the MediaBrowser

The media browser should be connected when the Activity or Fragment is started. Add the following method:

override fun onStart() {
  super.onStart()
    if (mediaBrowser.isConnected) {
      val fragmentActivity = activity as FragmentActivity  
      if (MediaControllerCompat.getMediaController
          (fragmentActivity) == null) {
        registerMediaController(mediaBrowser.sessionToken)
      }
    } else {
      mediaBrowser.connect()
    }
}

First, check to see if the media browser is already connected. This happens when a configuration change occurs, such as a screen rotation. If it’s connected, then all that’s needed is to register the media controller. If it’s not connected, then you call connect() and delay the media controller registration until the connection is complete.

Unregister the controller

The media controller callbacks should be unregistered when the Activity or Fragment is stopped.

Add the following method:

override fun onStop() {
  super.onStop()
  val fragmentActivity = activity as FragmentActivity  
  if (MediaControllerCompat.getMediaController(fragmentActivity)
      != null) {
    mediaControllerCallback?.let {
      MediaControllerCompat.getMediaController(fragmentActivity)
          .unregisterCallback(it)
    }
  }
}

If the media controller is available and the mediaControllerCallback is not null, the media controller callbacks object is unregistered.

It’s time to make sure everything is connected correctly before adding some playback code.

Build and run the app. Display the details for a podcast. Look at Logcat, and notice that things did not go as planned.

There are error messages from the MediaBrowserService and the MediaBrowser. Also, onConnectionFailed() was called on your MediaBrowserCallBacks object.

I/MediaBrowserService: No root for client com.raywenderlich.podplay from service android.service.media.MediaBrowserService$ServiceBinder$1
E/MediaBrowser: onConnectFailed for ComponentInfo{com.raywenderlich.podplay/com.raywenderlich.podplay.service.PodplayMediaService}
I/System.out: onConnectionFailed

Handle media browsing

To properly handle media browsing, there’s one part of PodplayMediaService you need to complete.

onGetRoot() and onLoadChildren() are designed to work in concert and provide a hierarchy of media content to a media browser. A media browser calls these two methods to get a list of browsable menu items to show the user.

onGetRoot() should return the root media ID of the content tree. onLoadChildren() should return the list of child media items given a parent media ID. If onGetRoot() returns null then the connection fails.

Media browsing is an optional feature, and a media browser can still connect to and control a media service without full media browsing capabilities. PodPlay will not allow media browsing, but you still need to return an empty root ID from onGetRoot().

Define a new media ID representing the empty root media and return it in onGetRoot().

Open PodplayMediaService.kt and add the following companion object:

companion object {
  private const val PODPLAY_EMPTY_ROOT_MEDIA_ID = 
      "podplay_empty_root_media_id"
}

Replace the contents of onGetRoot() with the following:

return MediaBrowserServiceCompat.BrowserRoot(
    PODPLAY_EMPTY_ROOT_MEDIA_ID, null)

Next, you need to tell onLoadChildren() to return an empty list of children for the empty root ID.

Replace the contents of onLoadChildren() with the following:

if (parentId.equals(PODPLAY_EMPTY_ROOT_MEDIA_ID)) {
  result.sendResult(null)
}

Build and run the app. Display the details for a podcast. Look at Logcat, and you’ll see the onConnected message indicating the media browser connected to the media browser service without any problems.

I/System.out: onConnected

Sending playback commands

With the successful connection in place, it’s time to test out the ability to send play commands and recognize state changes.

For now, and to keep things simple, you’ll send a play command to the PodplayMediaService when the user taps on a podcast episode.

Start by adding some code to detect when the user taps on an episode.

Open EpisodeListAdapter.kt and add the following to the top of the class:

interface EpisodeListAdapterListener {
  fun onSelectedEpisode(episodeViewData: EpisodeViewData)
}

PodcastDetailsFragment will implement this interface and get notified when the user taps an episode.

Update the EpisodeListAdapter class definition to match the following:

class EpisodeListAdapter(
    private var episodeViewList: List<EpisodeViewData>?,
    private val episodeListAdapterListener: 
        EpisodeListAdapterListener) :
    RecyclerView.Adapter<EpisodeListAdapter.ViewHolder>() {

This adds the episodeListAdapterListener argument to the constructor.

Update the inner ViewHolder class definition to the following:

class ViewHolder(
    v: View, private
    val episodeListAdapterListener: 
        EpisodeListAdapterListener) :
    RecyclerView.ViewHolder(v) {

This adds the episodeListAdapterListener argument to the class declaration.

Update the return in onCreateViewHolder() to add in the new argument:

return ViewHolder(LayoutInflater.from(parent.context)
    .inflate(R.layout.episode_item, parent, false),
        episodeListAdapterListener)

Add the following method to the inner ViewHolder class:

init {
  v.setOnClickListener {
    episodeViewData?.let {
      episodeListAdapterListener.onSelectedEpisode(it)
    }
  }
}

You set an onClickListener on the view holder. When the user taps an episode, onSelectedEpisode() is called on the adapter listener.

That’s it! EpisodeListAdapter now calls onSelectedEpisode() when the user taps an episode.

From here, you can make PodcastDetailsFragment implement the episodeListAdapterListener interface. First, you need to define a method to start the playback from an EpisodeViewData item.

Open PodcastDetailsFragment.kt and add the following method:

private fun startPlaying(
    episodeViewData: PodcastViewModel.EpisodeViewData) {    
  val fragmentActivity = activity as FragmentActivity
  val controller = 
      MediaControllerCompat.getMediaController(fragmentActivity)
  controller.transportControls.playFromUri(
      Uri.parse(episodeViewData.mediaUrl), null)
}

This method takes a single EpisodeViewData item and uses the media controller transport controls to initiate the media playback. The call to playFromUri() triggers the onPlayFromUri() callback in PodplayMediaService.

Next, you need to implement the episodeListAdapterListener interface in PodcastDetailsFragment.

Update the PodcastDetailsFragment class definition as follows:

class PodcastDetailsFragment : Fragment(), EpisodeListAdapterListener {

Add the following method to implement the onSelectedEpisode logic:

override fun onSelectedEpisode(episodeViewData: EpisodeViewData) {
  // 1
  val fragmentActivity = activity as FragmentActivity
  // 2
  val controller = 
      MediaControllerCompat.getMediaController(fragmentActivity)
  // 3
  if (controller.playbackState != null) {
    if (controller.playbackState.state ==
        PlaybackStateCompat.STATE_PLAYING) {
      // 4
     controller.transportControls.pause()
    } else {
      // 5
      startPlaying(episodeViewData)
    }
  } else {
    // 6
    startPlaying(episodeViewData)
  }
}

This is called when the user taps an episode. The current episode either plays or pauses depending on the current playback state. Let’s go over things in detail:

  1. You assign a local fragmentActivity to activity since activity is a property that can change to null between calls.
  2. You get the media controller that was previously assigned to the Activity.
  3. If the playback state is not null, then you check the state.
  4. If the playback state is “playing”, then you pause the episode using the transport controls.
  5. If the playback state is “paused”, then you call startPlaying() to play the episode.
  6. If the playback state is null, then you call startPlaying() to play the episode.

In setupControls(), update the call to EpisodeListAdapter() to pass in the EpisodeListAdapterListener argument:

episodeListAdapter =
    EpisodeListAdapter(
        podcastViewModel.activePodcastViewData?.episodes,
        this)

Updating media session state

Finally, it’s time to update the media service to set the playback states based on the incoming play commands.

Open PodplayMediaCallback.kt and add the following method to the class:

private fun setState(state: Int) {
  var position: Long = -1

  val playbackState = PlaybackStateCompat.Builder()
  .setActions(
      PlaybackStateCompat.ACTION_PLAY or
          PlaybackStateCompat.ACTION_STOP or
          PlaybackStateCompat.ACTION_PLAY_PAUSE or
          PlaybackStateCompat.ACTION_PAUSE)
  .setState(state, position, 1.0f)
  .build()

  mediaSession.setPlaybackState(playbackState)
}

This is a helper method to set the current state on the media session. The media session state is configured with a PlaybackState object that provides a Builder to set all of the options. This takes a simple playback state such as STATE_PLAYING and uses it to construct the more complex PlaybackState object. setActions() specifies what states the media session will allow.

Now you can use this method to update the state as playback commands are processed.

Add the following line to the end of onPlayFromUri():

mediaSession.setMetadata(MediaMetadataCompat.Builder()
    .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI,
        uri.toString())
    .build())

Metadata is set on the mediaSession object to use the METADATA_KEY_MEDIA_URI key. You can set a variety of metadata on the media session — you’ll add more later. This data is used by media browsers to display details about the audio track being played.

Add the following line to the end of onPlay():

setState(PlaybackStateCompat.STATE_PLAYING)

When receiving the play command, you set the media session playback state to STATE_PLAYING.

Add the following line to the end of onPause():

setState(PlaybackStateCompat.STATE_PAUSED)

When receiving the pause command, you set the media session playback state to STATE_PAUSED.

You aren’t playing or pausing anything yet, but at least the state is set correctly!

Build and run the app. Once again, display the details for a podcast, then tap on a single episode and then tap on it again.

You’ll see the following output in Logcat showing that the onPlay and onPause methods are getting called in the media service, and the state changes are getting picked up by the media controller callbacks.

I/System.out: onConnected
I/System.out: onPlayFromUri https://audio.simplecast.com/2be4cd5d.mp3
I/System.out: onPlay
I/System.out: metadata changed to https://audio.simplecast.com/2be4cd5d.mp3
I/System.out: state changed to PlaybackState {state=3, position=0, buffered position=0, speed=1.0, updated=71964629, actions=519, error code=0, error message=null, custom actions=[], active item id=-1}
I/System.out: onPause
I/System.out: state changed to PlaybackState {state=2, position=0, buffered position=0, speed=1.0, updated=71975052, actions=519, error code=0, error message=null, custom actions=[], active item id=-1}

Using MediaPlayer

Now that you have the MediaBrowser talking to the MediaBrowserService, it’s time to hear some audio. However, it’s up to you to provide the media playback capabilities in response to the media session events. You can use any means you want to play back the media, including third-party media players.

For PodPlay, Android’s built-in MediaPlayer will do the job. In this section, after creating the MediaPlayer, you’ll add a few helper methods to control playback.

To begin using MediaPlayer, you need to initialize it when playback is first requested for a given media item. You’ll store the most recently requested media item and keep track of whether the item is new or not.

Add the following properties to the PodplayMediaCallback class:

private var mediaUri: Uri? = null
private var newMedia: Boolean = false
private var mediaExtras: Bundle? = null

mediaUri keeps track of the currently playing media item, and newMedia indicates if it’s a new item. mediaExtras keeps track of the media information passed into onPlayFromUri().

Next, create a method to store a new media item and set the metadata on the media session.

Add the following method:

private fun setNewMedia(uri: Uri?) {
  newMedia = true
  mediaUri = uri
}

This sets the newMedia flag to true, and stores the current media in mediaUri.

Audio Focus

Android uses the concept of audio focus to make sure that apps cooperate with each other and the system, ensuring that audio is played at the appropriate times. Only one app has audio focus at a time, although more than one app can play audio at the same time.

For instance, if you have a navigation app running that needs to announce an upcoming turn, it will request audio focus. If another app, such as PodPlay is playing a podcast, it will receive notification that it should pause or lower the volume while the navigation instructions are announced.

Android changed the way the audio focus is controlled starting with Android 8.0 (API Level 26). The new method is not compatible with older versions of Android, so you need to write slightly different code based on the version that the user is running.

First, create a method that requests audio focus.

Add the following property to the PodplayMediaCallback class:

private var focusRequest: AudioFocusRequest? = null

This is used in the code below to store an audio focus request when running Android 8.0 and above.

Next, add the following method:

private fun ensureAudioFocus(): Boolean {
  // 1
  val audioManager = this.context.getSystemService(
      Context.AUDIO_SERVICE) as AudioManager

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    // 2
    val focusRequest = 
      AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
          .run {
          setAudioAttributes(AudioAttributes.Builder().run {
              setUsage(AudioAttributes.USAGE_MEDIA)
              setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
              build()
          })
          build()
          }
    // 3
    this.focusRequest = focusRequest
    // 4
    val result = audioManager.requestAudioFocus(focusRequest)
    // 5
    return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
  } else {
    // 6
    val result = audioManager.requestAudioFocus(null,
        AudioManager.STREAM_MUSIC,
        AudioManager.AUDIOFOCUS_GAIN)
    // 7
    return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
  }
}

Note: Android Studio will complain that the second requestAudioFocus call is deprecated. While it is deprecated in newer versions of Android, you must use it in the older version of Android since PodPlay supports version 4.4 and newer.

Here’s the break down:

  1. The AudioManager system service object is obtained.

  2. If the version of Android is 8 (Android O) or newer, then an AudioFocusRequest object is generated using the AudioFocusRequest builder and stored in a local variable. The builder requires a single focusGain parameter, which is set to AUDIOFOCUS_GAIN. This tells Android that you want to gain audio focus and are about to start playing audio. A set of audio attributes are defined on the focus request to indicate that you are using media (USAGE_MEDIA) and the content type is music (CONTENT_TYPE_MUSIC). Other types of usage and content types can be set for different scenarios.

  3. The class property focusRequest is assigned to the local focusRequest variable.

  4. The call is made to requestAudioFocus() passing in the focusRequest.

  5. True is returned if the focus request was granted; otherwise False is returned.

  6. If the version of Android is less than 8, then a call is made to requestAudioFocus() passing in the following parameter types:

    OnAudioFocusChangeListener: This is an optional callback allowing you to respond to audio focus changes. You won’t handle focus changes in PodPlay, so the value is set to null.

    streamType: This is the type of audio stream, and is similar to the content type used in Android 8 above.

    durationHint: This is equivalent to the focusGain parameter in Android 8, and is set to AUDIOFOCUS_GAIN.

  7. true is returned if the focus request was granted; otherwise false is returned.

Now you can use this new method to make sure you have audio focus before playback is started.

Update onPlay() after the call to super to surround the code with a call to ensureAudioFocus(), like so:

if (ensureAudioFocus()) {
  mediaSession.isActive = true
  setState(PlaybackStateCompat.STATE_PLAYING)
}

You also need a method to give up audio focus. Add the following method:

private fun removeAudioFocus() {
  val audioManager = this.context.getSystemService(
      Context.AUDIO_SERVICE) as AudioManager

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    focusRequest?.let {
      audioManager.abandonAudioFocusRequest(it)
    }
  } else {
    audioManager.abandonAudioFocus(null)
  }
}

Note: Android Studio will complain that the abandonAudioFocus call is deprecated. While it is deprecated in newer versions of Android, you must use it to support older versions, including version 4.4, which PodPlay supports.

You’ll call this method any time you pause or stop audio playback.

Just like the request to gain audio focus, this call changed starting with Android 8. If using Android 8 or newer, you call abandonAudioFocusRequest() and pass it the focusRequest that was obtained when gaining focus. If using a version before Android 8, you call abandonAudioFocus().

Note: The above code is the minimum required to let Android know when you need audio focus so it can properly inform other apps. You’re encouraged to review the full details of audio focus at https://bit.ly/2ryV5dZ. You can read about the different options for building audio requests, and how to implement an audio focus listener to handle focus changes in PodPlay.

Now, create a method to initialize the MediaPlayer.

Add the following method:

private fun initializeMediaPlayer() {
  if (mediaPlayer == null) {
    mediaPlayer = MediaPlayer()
    mediaPlayer!!.setOnCompletionListener({
      setState(PlaybackStateCompat.STATE_PAUSED)
    })
  }
}

This creates a new instance of the MediaPlayer if it doesn’t already exist. It also sets up a listener for when playback completes and pauses the player upon completion.

Remove the call to mediaSession.setMetadata from onPlayFromUri() since it’s called here instead.

Create a method to prepare the media for the MediaPlayer.

Add the following method to PodplayMediaCallback:

private fun prepareMedia() {
  if (newMedia) {
    newMedia = false
    mediaPlayer?.let { mediaPlayer ->
      mediaUri?.let { mediaUri ->
        mediaPlayer.reset()
        mediaPlayer.setDataSource(context, mediaUri)
        mediaPlayer.prepare()
        mediaSession.setMetadata(MediaMetadataCompat.Builder()
          .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI,
              mediaUri.toString())
        .build())        
      }
    }
  }
}

If it’s a new media item and the media player and media URI are valid, the media player state is reset, and the data source is set to the media item. Once the data source is set, then prepare is called. prepare() puts the MediaPlayer in an initialized state ready to play the media provided as the data source.

Previously, the setState() you defined assigned a playback position of -1. Now that you have a media player, you can update this to grab the position from the player.

Add the following after the var position: Long = -1 line in setState():

mediaPlayer?.let {
  position = it.currentPosition.toLong()
}

Add the following method to start the playback of the audio media.

private fun startPlaying() {
  mediaPlayer?.let { mediaPlayer ->
    if (!mediaPlayer.isPlaying) {
      mediaPlayer.start()
      setState(PlaybackStateCompat.STATE_PLAYING)
    }
  }
}

If the mediaPlayer is not null and it’s not already playing, then you instruct it to play the media. You also set the media session state to STATE_PLAYING.

Add the following method to pause playback of the audio media.

private fun pausePlaying() {
  removeAudioFocus()
  mediaPlayer?.let { mediaPlayer ->
    if (mediaPlayer.isPlaying) {
      mediaPlayer.pause()
      setState(PlaybackStateCompat.STATE_PAUSED)
    }
  }
}

Start by removing the audio focus from the app. If the mediaPlayer is not null and it’s already playing, then you instruct it to pause the media. You also set the media session state to STATE_PAUSED.

Finally, you need to handle the case where playback is stopped.

Add the following method to PodplayMediaCallback:

private fun stopPlaying() {
  removeAudioFocus()
  mediaSession.isActive = false
  mediaPlayer?.let { mediaPlayer ->
    if (mediaPlayer.isPlaying) {
      mediaPlayer.stop()
      setState(PlaybackStateCompat.STATE_STOPPED)
    }
  }
}

This is similar to pausePlaying(), but it sets the media session to inactive and the state to STATE_STOPPED.

That’s all of the supporting methods; now you need to call them at the appropriate times.

Add the following lines before the call to onPlay() in onPlayFromUri():

if (mediaUri == uri) {
  newMedia = false
  mediaExtras = null
} else {
  mediaExtras = extras
  setNewMedia(uri)
}

If the uri passed in is the same as before, then the newMedia flag is set to false, and mediaExtras is set to null. There is no need to set the new media or mediaExtras if a new media item is not being set. If the uri is new, then the media extras are stored and setNewMedia() is called.

Replace the call to setState(PlaybackStateCompat.STATE_PLAYING) in onPlay() with the following lines:

initializeMediaPlayer()
prepareMedia()
startPlaying()

The media player is initialized, the media is prepared for playback, and then the media player is told to start playing.

Replace the call to setState(PlaybackStateCompat.STATE_PAUSED) in onPause() with the following:

pausePlaying()

Call stopPlaying() when the event comes in. Add the following line to the end of onStop():

stopPlaying()

Build and run the app.

Display the details for a podcast and tap on an episode. Make sure your audio is turned up on your device or emulator. The episode should start streaming within a few seconds.

Note: If you don’t hear any sound and are running on the emulator, check your computer’s default sound output. Also, check Logcat, and if you see playback errors, try restarting both the emulator and Android Studio, then retry.

Tap the episode again, and the playback pauses. Tap the same episode and playback begins again where it left off.

Congratulations — you’re finally able to listen to a podcast. Now that basic playback is working, it’s time to take the service to the next level and make it a true foreground service. As it stands now, the service runs in the background and is likely to get killed by Android at any time. It’ll also get shut down if you close PodPlay.

Foreground service

To keep the audio playing, you need to set PodplayMediaService as a foreground service. Any foreground service requires that it display a visible notification to the user. This is done at the time the podcast begins playing.

Media notification

To display the notification, you’ll build it using the same APIs as you did the new episode notification in the last chapter, but this time the expanded notification will display playback controls. You’ll use a special style named MediaStyle on the notification that automatically displays and handles the playback controls.

You’ll assign two possible actions to the notification: a play action for when the media is not currently playing, and a pause action for when the media is currently playing. Whenever the media playback state changes, the notification gets replaced and the appropriate action assigned.

Start by creating the two possible notification actions:

Open PodplayMediaService.kt and add the following method:

private fun getPausePlayActions():
    Pair<NotificationCompat.Action, NotificationCompat.Action>  {
  val pauseAction = NotificationCompat.Action(
      R.drawable.ic_pause_white, getString(R.string.pause),
      MediaButtonReceiver.buildMediaButtonPendingIntent(this,
          PlaybackStateCompat.ACTION_PAUSE))

  val playAction = NotificationCompat.Action(
      R.drawable.ic_play_arrow_white, getString(R.string.play),
      MediaButtonReceiver.buildMediaButtonPendingIntent(this,
          PlaybackStateCompat.ACTION_PLAY))

  return Pair(pauseAction, playAction)
}

Note: Choose androidx.core.app.NotificationCompat for the NotificationCompat import.

You create pause and play actions and return them to the caller. Each action has an associated icon, title and pending Intent. buildMediaButtonPendingIntent() creates a pending Intent that triggers a playback action on the media service.

Add the following strings to the strings.xml file:

<string name="pause">Pause</string>
<string name="play">Play</string>

To decide whether to use the pause or play action, you need a method to determine if the MediaPlayer is currently playing media.

Add the following method to PodplayMediaService:

private fun isPlaying(): Boolean {
  if (mediaSession.controller.playbackState != null) {
    return mediaSession.controller.playbackState.state ==
        PlaybackStateCompat.STATE_PLAYING
  } else {
    return false
  }
}

This checks the current playback state and returns true if it is playing.

The Notification also needs a pending Intent to launch the main PodcastActivity when the notification is tapped.

Add the following method:

private fun getNotificationIntent(): PendingIntent {
  val openActivityIntent = Intent(this, 
      PodcastActivity::class.java)
  openActivityIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
  return PendingIntent.getActivity(
      this@PodplayMediaService, 0, openActivityIntent,
      PendingIntent.FLAG_CANCEL_CURRENT)
}

This creates a pending intent that will open the PodcastActivity.

Notifications also require a channel. Create a new channel ID and a method to create the channel.

Add the following line to the companion object in PodplayMediaService:

private const val PLAYER_CHANNEL_ID = "podplay_player_channel"

Add the following method:

@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
  val notificationManager =
      getSystemService(Context.NOTIFICATION_SERVICE)
        as NotificationManager
  if (notificationManager.getNotificationChannel
     (PLAYER_CHANNEL_ID) == null) {
    val channel = NotificationChannel(PLAYER_CHANNEL_ID, 
        "Player", NotificationManager.IMPORTANCE_LOW)
    notificationManager.createNotificationChannel(channel)
  }
}

This is similar to the channel you created for the episode update notification in the last chapter. The only difference is the channel ID.

You’re ready to build out the notification. Add the following method:

// 1
private fun createNotification(mediaDescription: MediaDescriptionCompat,
                               bitmap: Bitmap?): Notification {

  // 2
  val notificationIntent = getNotificationIntent()
  // 3
  val (pauseAction, playAction) = getPausePlayActions()
  // 4
  val notification = NotificationCompat.Builder(
      this@PodplayMediaService, PLAYER_CHANNEL_ID)
  // 5
  notification
      .setContentTitle(mediaDescription.title)
      .setContentText(mediaDescription.subtitle)
      .setLargeIcon(bitmap)
      .setContentIntent(notificationIntent)
      .setDeleteIntent(
          MediaButtonReceiver.buildMediaButtonPendingIntent
          (this, PlaybackStateCompat.ACTION_STOP))
      .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
      .setSmallIcon(R.drawable.ic_episode_icon)
      .addAction(if (isPlaying()) pauseAction else playAction)
      .setStyle(
              androidx.media.app.NotificationCompat.MediaStyle()
              .setMediaSession(mediaSession.sessionToken)
              .setShowActionsInCompactView(0)
              .setShowCancelButton(true)
              .setCancelButtonIntent(
                  MediaButtonReceiver.
                      buildMediaButtonPendingIntent(
                      this, PlaybackStateCompat.ACTION_STOP)))
  // 6
  return notification.build()
}

Here are the details:

  1. The method accepts a MediaDescriptionCompat object and a bitmap. These contain all of the details required to construct the notification.

  2. The main notification intent is created. This is set as the content Intent on the notification and is what allows the PodcastActivity to launch when the notification is tapped.

  3. The pause and play actions are created.

  4. The notification builder is created using the player channel ID.

  5. The builder is used to create the details of the notification.

    setContentTitle: Sets the main title on the notification from the media description title.

    setContentText: Sets the content text on the notification from the media description subtitle.

    setLargeIcon: Sets the icon (album art) to display on the notification.

    setContentIntent: Set the content Intent, so PodPlay is launched when the notification is tapped.

    setDeleteIntent: Send an ACTION_STOP command to the service if the user swipes away the notification.

    setVisibility: Make sure the transport controls are visible on the lock screen.

    setSmallIcon: Set the icon to display in the status bar.

    addAction: Add either the play or pause action based on the current playback state.

    setStyle: Uses the special MediaStyle to create a style that is designed to display up to five transport control buttons in the expanded view.

    The following items are used to control how the MediaStyle behaves:

    setStyle.setMediaSession: Indicates that this is an active media session. The system uses this as a flag to activate special features such as showing album artwork and playback controls on the lock screen.

    setStyle.setShowActionsInCompactView: Indicates which action buttons to display in compact view mode. This takes up to three index numbers to specify the order of the controls.

    setStyle.setShowCancelButton: Displays a cancel button on versions of Android before Lollipop (API 21).

    setStyle.setCancelButtonIntent(): Pending Intent to use when the cancel button is tapped.

  6. The notification is built and returned to the caller.

Now tie this all together and create a method to display the notification.

First, you need a unique notification ID when starting the foreground service.

Add the following to the companion object:

private const val NOTIFICATION_ID = 1

Add the following method to PodplayMediaService:

private fun displayNotification() {
  // 1
  if (mediaSession.controller.metadata == null) {
    return
  }
  // 2
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    createNotificationChannel()
  }
  // 3
  val mediaDescription = 
      mediaSession.controller.metadata.description
  // 4
  GlobalScope.launch {
    // 5
    val iconUrl = URL(mediaDescription.iconUri.toString())
    // 6
    val bitmap = 
        BitmapFactory.decodeStream(iconUrl.openStream())
    // 7
    val notification = createNotification(mediaDescription, 
        bitmap)
    // 8
    ContextCompat.startForegroundService(
        this@PodplayMediaService,
        Intent(this@PodplayMediaService, 
            PodplayMediaService::class.java))
    // 9
    startForeground(PodplayMediaService.NOTIFICATION_ID, 
        notification)
  }
}

Note: Make sure to choose java.net.URL as the URL import.

  1. If there is no metadata on the mediaSession.controller, then the method is abandoned.
  2. Android O or newer requires a notification channel.
  3. The MediaDescription is extracted from the media session.
  4. A coroutine is launched in the background so the album artwork can be loaded from the network.
  5. A URL object is created based on the album artwork icon internet location. This allows you to load the image over the network.
  6. A stream is opened on the iconUrl and passed to the BitmapFactory.decodeStream(). decodeStream() loads the image from the internet and it’s stored in the bitmap object.
  7. After the image is loaded, you create the notification using the description of the podcast episode and the album art bitmap.
  8. startForegroundService() starts the service in foreground mode.
  9. startForeground() displays the notification icon. You pass in a unique notification ID and the notification object.

Now, display the notification when the playback starts or pauses, and hide it when playback stops.

To do this, you need to know when playback has started, and that is handled in the PodplayMediaCallback class.

You’ll create a listener object on PodplayMediaCallback so it can emit some key events to the MediaBrowserService class.

Note: You may be wondering why the notification code wasn’t included directly in PodplayMediaCallback instead of setting up the listener and handling it in MediaBrowserService. The reason is that PodplayMediaCallback will be shared by the video player in the next chapter and notifications are specific to the media browser service implementation.

Open PodplayMediaCallback.kt and add the following interface to the class:

interface PodplayMediaListener {
  fun onStateChanged()
  fun onStopPlaying()
  fun onPausePlaying()
}

Three methods are defined that PodplayMediaCallback will call in response to key playback events.

First, you need a listener property that the media browser service can set.

Add the following property to PodplayMediaCallback:

var listener: PodplayMediaListener? = null

Call onStateChanged() when the state changes to playing or paused.

Add the following to the end of setState():

if (state == PlaybackStateCompat.STATE_PAUSED ||
    state == PlaybackStateCompat.STATE_PLAYING) {
  listener?.onStateChanged()
}

Call onStopPlaying() when playback stops.

Add the following to the end of stopPlaying():

listener?.onStopPlaying()

Call onPausePlaying() when playback pauses.

Add the following to the end of pausePlaying():

listener?.onPausePlaying()

You’re ready to implement PodplayMediaListener on the media browser service.

Open PodplayMediaService.kt and update the class declaration to the following:

class PodplayMediaService : MediaBrowserServiceCompat(), 
    PodplayMediaListener {

Add the following methods to implement the PodplayMediaListener interface:

override fun onStateChanged() {
  displayNotification()
}

override fun onStopPlaying() {
  stopSelf()
  stopForeground(true)
}

override fun onPausePlaying() {
  stopForeground(false)
}

Here’s what each one does:

  • onStateChanged(): Displays the notification when the state changes between play and pause.

  • onStopPlaying(): Stops the service and removes it from the foreground. You pass in true to remove the notification at the same time. It’s important to stop the service when playback stops; otherwise, it keeps running indefinitely.

  • onPausePlaying(): Removes the service from the foreground but passes in false, so the notification is not removed.

Finally, you need to set the listener on the media session callback.

In PodplayMediaService.kt, add the following line in createMediaSession() before the call to mediaSession.setCallback():

callBack.listener = this

Media metadata

There’s still one missing part: You haven’t told the media service about the details of the podcast episode yet. You need to pass in the additional episode details and add them to the media session metadata.

Open PodcastDetailsFragment.kt and replace the following line in startPlaying():

controller.transportControls.playFromUri(
    Uri.parse(episodeViewData.mediaUrl), null)

With this:

val viewData = podcastViewModel.activePodcastViewData ?: return
val bundle = Bundle()
bundle.putString(MediaMetadataCompat.METADATA_KEY_TITLE,
    episodeViewData.title)
bundle.putString(MediaMetadataCompat.METADATA_KEY_ARTIST,
    viewData.feedTitle)
bundle.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
    viewData.imageUrl)

controller.transportControls.playFromUri(
    Uri.parse(episodeViewData.mediaUrl), bundle)

This grabs the active podcast data and uses it to create a bundle with some extra information to pass along to the playFromUri() call.

It’s up to you what keys to use and what information to pass in the Bundle. For consistency, use the same keys here that will be used when setting the metadata on the media session.

Now you can update the media service to read in the values from the bundle and set them as metadata on the media session.

Open PodplayMediaCallback.kt and replace the call to MediaSession.setMetadata in prepareMedia() with the following:

mediaExtras?.let { mediaExtras ->
  mediaSession.setMetadata(MediaMetadataCompat.Builder()
  .putString(MediaMetadataCompat.METADATA_KEY_TITLE, 
      mediaExtras.getString(
          MediaMetadataCompat.METADATA_KEY_TITLE))
  .putString(MediaMetadataCompat.METADATA_KEY_ARTIST,   
      mediaExtras.getString(
          MediaMetadataCompat.METADATA_KEY_ARTIST))
  .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,   
      mediaExtras.getString(
          MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI))
  .build())
}

This takes the three items set on the Bundle and uses them to set the metadata on the media session. This is used by the notification and the other media players to display details about the currently playing podcast episode.

Final pieces

One more item is required to stop the playback if the user dismisses the app from the recent applications list. Add the following method to PodplayMediaService:

override fun onTaskRemoved(rootIntent: Intent?) {
  super.onTaskRemoved(rootIntent)
  mediaSession.controller.transportControls.stop()
}

onTaskRemoved() is called if the user swipes away the app in the recent apps list. This stops the playback and removes the service. This is all you would need if running on API 21 or higher. For versions before API 21, you have to use a built-in broadcast receiver to get button events from the notification.

Add the following to the <application> section in AndroidManifest.xml:

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

Since API 28, you must now add a permission to run your app as a foreground service. If you fail to do this, your app will crash. Add the following additional permission to the manifest:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

There’s one last minor change to help improve the look of the album art when shown in the notification view: Update the iTunesPodcast model to use a higher resolution version of the album artwork.

Open PodcastResponse.kt and rename artworkUrl30 to artworkUrl100 in the iTunesPodcast class as follows:

val artworkUrl100: String,

Open SearchViewModel.kt and replace artworkUrl30 with artworkUrl100 in itunesPodcastToPodcastSummaryView():

itunesPodcast.artworkUrl100,

Build and run the app. Once again, display the details for a podcast, and tap on an episode to start it playing. This time, a notification icon displays in the status bar. Pull down the notification to reveal the expanded view. Tap on the pause button to pause the playback.

Note: If the app crashes when tapping on the pause button in the notification then make sure you have removed the call to mediaSession.setMetadata from onPlayFromUri() in PodplayMediaCallback.kt.

Depending on the version of Android you’re running, the notification will display with a different style. Notice on Android Oreo that the notification takes on a tint color based on the album artwork.

From left to right, Android Oreo (8), Android Marshmallow (6), Android Lollipop (5)
From left to right, Android Oreo (8), Android Marshmallow (6), Android Lollipop (5)

Press the play button to restart the audio playback, then exit PodPlay. The podcast keeps playing, and you can still control it from the notification view.

Turn off the phone and display the lock screen. The notification shows in the lock screen, allowing you to control the playback.

Android Marshmallow Lockscreens
Android Marshmallow Lockscreens

If you have an Android Wear watch that’s connected to your device, it will display a media playback screen allowing you to control the playback from the watch.

Android Wear
Android Wear

Where to go from here?

That was a lot of work to get playback working, but it’s worth it to have podcasts that play correctly in the background. Take a break and find a relaxing podcast to listen to while you get ready for the next chapter.

In the final chapter of this section, you’ll wrap up PodPlay by building a full episode details screen with playback controls. Plus, you’ll add a few more finishing touches.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.