Introduction to MediaPlayer – building a simple audio app in Android

Posted by

What is in this tutorial

Want to learn how to build a media player app in Android using the MediaPlayer API? If so, this tutorial is for you.

If you want to build a media player app in Android, you can choose between MediaPlayer and ExoPlayer. This tutorial is an in-depth guide to using MediaPlayer.

I am not going to cover:

  • Streaming audio playback with MediaPlayer.
  • Video playback with MediaPlayer (streaming or otherwise).
  • MediaSession, MediaBrowsingService, or MediaController. I’m going to cover these in detail in a future tutorial.

I will demonstrate the following:

  • How to use the MediaPlayer class (create, prepare, start, reset, seekTo) and get a working understanding of its state machine.
  • How to synchronize the audio playback duration with a Seekbar.
  • How to allow the Seekbar to jump to different time positions in the audio during playback.
  • Integrate with Android Activity lifecycle to handle screen rotations. This sample does not handle media playback in a service.

I will illustrate this using a sample app that I’ve created, which is a barebones audio media player that just plays one MP3 file from the res/raw folder.

Video of the app in action

The video above shows the app in action. You can see how screen rotations are handled, and how the Seekbar moves smoothly across the screen. Also, you can see all the MediaPlayer method calls being logged to the UI as well, so you can see what is going on with the player’s state transitions.

Source code on GitHub

The source code for this app is available here. This is the master branch. There’s also another version of this app which uses Executors instead of Handler to perform UI refreshes to sync audio playback duration to Seekbar position here. I will dive deeply into Handler vs Executor later on in this tutorial.

Your playback needs and how that maps to MediaPlayer

Here are the use cases that MediaPlayer needs to handle for your app’s audio playback needs:

  • Start playback / Play audio. This is handled by start().
  • Pause playback (once playback has started). This is handled by pause().
  • Stop playback and reset the MediaPlayer, so that you can load another media file into it. This is handled by reset().
  • Find out how long a song is (in ms). This is handled by getDuration().
  • Find out how far into a song (in ms) playback has progressed at any time. This is handled by getCurrentPosition().
  • Jump to a specific time position (in ms) in the song and play that. This is handled by seekTo(position).
  • Check to see if audio is being played back right now. This is handled by isPlaying().
  • Find out when a song is done playing. This is handled by attaching a MediaPlayer.OnCompletionListener. You will get a callback into your code via your attached listener via onCompletion().
  • Load a media file for playback. This is handled by 2 methods: setDataSource and prepare or prepareAsync. If your media is on the device, then you don’t really have to do much to prepare it for playback, but if it’s streaming, then you have to pay some attention to prepare as it might take an undetermined amount of time depending on network conditions.
  • Deallocate resources consumed by the player. This is handled by release(), which releases all the resources attached the player and it is no longer usable. Set your MediaPlayer reference to null after calling this method.

This list should cover your basic playback needs for audio content. This isn’t an exhaustive list, and there are more complex use cases that you might have. For the purposes of this tutorial, we are going to keep things simple and specific to MediaPlayer. You can also consult this API guide on MediaPlayer.

State Machine

The MediaPlayer has a very sophisticated state machine that you don’t really need to fully understand in order to use it to control audio playback. Here is the state machine that you might want to look at later, if you have more advanced needs / use cases that your app must implement.

Here is a simplified description of this state machine for audio playback:

  1. You have to first create a MediaPlayer
  2. You then have to load the media file that you want into the player. This means using the setDataSource() method with a parameter that describes where the media file is coming from. You can re-use this MediaPlayer instance and set other sources. If you’re done with this object, then release() it and nullify your reference to this instance.
    1. You have to tell the MediaPlayer to actually load media from the source using the prepare() or prepareAsync() methods. These actually end up loading the media and get the player ready to start() playback. You should not call prepare() (especially if you’re loading content from a network on the UI thread) since it will block the calling thread until the media is loaded.
    2. Using prepareAsync you get a callback when your media is ready for playback, and you can enable media playback controls in your app’s UI at this point. You can use the MediaPlayer.OnPreparedListner to get notified when the player has loaded the content for you to start playback. You can attach this listener to your player using setOnPreparedListener(listener).
  3. Now that your audio has been loaded, it’s time for you to play and pause your audio. And you can stop playback and start again.
    1. play – use start() to tell the player to start playback. You can ask the player if it’s playing using isPlaying().
    2. pause – you can pause only if the isPlaying() is true. So always check this before you call pause().
    3. stop – use the reset() method to do this. It will stop playback and reset the player so that you can load other media files into it.

Sample app

The app uses the parts of the state machine described above. You can look at MediaPlayerFragment class of the sample app to see this in action. The MainActivity creates a UI that leverages the fragment in order to manage the player instance. There’s a reason for using a Fragment to manage the player instance, which is described in depth later in this tutorial

Loading Media (prepare, prepareAsync)

To keep things simple, I load the audio files from the res/raw folder in this project. The mp3 files in this folder are all creative commons licensed files from soundbible.com.

Playback controls (start, reset, stop, seekTo)

app screenshot

There are only 3 buttons in the UI of the sample app – PLAY, PAUSE, RESET. Here’s what the code in MainActivity related to this.

@OnClick(R.id.btnPause)
void pause() {
  scheduleNextTaskToRun();
  mMediaPlayerFragment.pause();
}

@OnClick(R.id.btnPlay)
void play() {
  scheduleNextTaskToRun();
  mMediaPlayerFragment.play();
}

@OnClick(R.id.btnReset)
void reset() {
  scheduleNextTaskToRun();
  mMediaPlayerFragment.reset();
}

Here’s the code in the MediaPlayerFragment that actually manages the MediaPlayer.

public void create() {
  mMediaPlayer = new MediaPlayer();
  log("new MediaPlayer()");
}

public void play() {
  log(String.format("start() %s",
                    getResources().getResourceEntryName(media_res_id)));
  mMediaPlayer.start();
}

public void pause() {
  if (mMediaPlayer.isPlaying()) {
    mMediaPlayer.pause();
  } else {
    Toast.makeText(mMainActivity,
                   "Can't pause if not playing",
                   Toast.LENGTH_SHORT)
         .show();
  }
  log("pause()");
}

public void reset() {
  log("reset()");
  mMediaPlayer.reset();
  load();
}

public void load() {
  AssetFileDescriptor afd = this.getResources().openRawResourceFd(media_res_id);
  try {
    log("load() {1. setDataSource}");
    mMediaPlayer.setDataSource(afd);
  } catch (Exception e) {
    log(e.toString());
  }

  try {
    log("load() {2. prepare}");
    mMediaPlayer.prepare();
  } catch (Exception e) {
    log(e.toString());
  }
}

Synchronize with Seekbar

The app’s UI also has a Seekbar. This does 2 things in the sample app:

  1. It provides visual feedback to the user as media playback is progressing.
  2. Allows the user the ability to jump to any specific time location in the media using seekTo().

Setting the Seekbar max to match the playback duration

Before the Seekbar can be used, it has to be set up so that it matches the audio that is currently loaded. This happens in the MediaPlayerFragment as follows.

public void initSeekbar() {
  int length = mMediaPlayer.getDuration();
  int seekBarMax = mMainActivity.mSeekbarAudio.getMax();
  if (seekBarMax != length) {
    log(String.format("setting seekbar max %d sec",
                      TimeUnit.MILLISECONDS.toSeconds(length)));
    mMainActivity.mSeekbarAudio.setMax(length);
  }
}

Jump to any position in the media using seekTo

MainActivity has the following code in it that makes this happen.

public void setupSeekbar() {
  mSeekbarAudio.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
    // this holds the progress value for onStopTrackingTouch
    int mProgressValue = 0;

    // don't fire seekTo() calls on every change
    @Override
    public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
      if (fromUser) {
        mProgressValue = progress;
      }
    }

    @Override
    public void onStartTrackingTouch(SeekBar seekBar) {
      clearTaskFromRunning();
    }

    @Override
    public void onStopTrackingTouch(SeekBar seekBar) {
      scheduleNextTaskToRun();
      mMediaPlayerFragment.seekTo(mProgressValue);
    }
  });
}

Provide visual feedback as audio playback progresses

As the audio playback progresses, the Seekbar‘s setProgress(time) has to be called, with the amount of time that has elapsed during playback. There’s no listener that can be attached to the MediaPlayer to get this information, so you’re going to have to poll. There are 2 strategies when you do this – use Handler, or Executor. In this app, I use both. However, in the master branch, I show you how to use the Handler as follows.

A Handler dispatches a Runnable to execute every 50 ms (POLLING_INTERVAL_MS). This means that at this regular interval a this task is executed which paints the UI with the current position of MediaPlayer position. If you made this a larger number like 1000 (1 sec), then the Seekbar would be really choppy in it’s updates. You can try changing this to different numbers and press the PLAY button to see how this changes the UI behavior.

public void clearTaskFromRunning() {
  mHandler.removeCallbacks(mSeekbarPlaybackUpdateTask);
}

public void scheduleNextTaskToRun() {
  clearTaskFromRunning();
  mHandler.postDelayed(mSeekbarPlaybackUpdateTask, POLLING_INTERVAL_MS);
}

/**
 * update the seekbar with the position of the music
 */
private Runnable mSeekbarPlaybackUpdateTask = () -> {
  try {

    int mediaPosition = mMediaPlayerFragment.getCurrentPosition();
    int seekbarPosition = mSeekbarAudio.getProgress();

    // play was pressed and the audio played to completion in the
    // previous playback session
    if (mForceRefresh) {
      mSeekbarAudio.setProgress(mediaPosition, true);
      mForceRefresh = false;
      scheduleNextTaskToRun();
      return;
    }

    // catch when there are janky jumps in the Seekbar and drop them
    // (don't update the UI with this janky value)
    if (mMediaPlayerFragment.isPlaying()) {
      if (mediaPosition <= seekbarPosition) {
        // skip updating the Seekbar
        Log.d(TAG, "JANK!");
        scheduleNextTaskToRun();
        return;
      }
    }

    // actually update teh Seekbar under most normal media playing conditions
    mSeekbarAudio.setProgress(mediaPosition, true);

    if (mMediaPlayerFragment.isPlaying()) {
      // schedule the next execution of this task
      scheduleNextTaskToRun();
    } else {
      // audio has reached the very end of playback (for the media)
      // make sure to update the Seekbar with the media position
      // set the flag to force repaint next time that Reset or Play are pressed
      clearTaskFromRunning();
      mSeekbarAudio.setProgress(mediaPosition, true);
      mForceRefresh = true;
    }

  } catch (Exception e) {
    Log.d(TAG, "MediaPlayer has been deallocated ... task ending");
  }
};

Here’s the version of code that uses Executor instead of Handler. For more info on Executors, check out this tutorial.

Activity Lifecycle Integration

When you’re playing audio and you change your screen orientation, then there might be issues with playback. This tutorial is just focused on MediaPlayer, and it doesn’t go into the client server architecture you would need to implement in order to handle audio playback in the background even when your app’s activities aren’t running or in the foreground.

In order to respect the lifecycle of MediaPlayer and not to introduce memory leaks, I use a Fragment with setRetainInstance(true) in order to keep the same MediaPlayer object around even thru screen orientation changes, where the Activity is getting destroyed and created repeatedly. Here’s a good tutorial that does a deep dive of Fragments and setRetainInstance(true).

Why do I need a Fragment to hold the MediaPlayer?

The MediaPlayer object that we use is created when the app launches. And if this were placed in the MainActivity then every time the screen orientation would change, a new MediaPlayer object would be created. This is bad because we could call start() on an object, and then rotate the screen, and it would still continue to play, but we would no longer be able to stop(), pause(), etc, ie we would lose control over this, and this object would be leaked, until it’s application process was destroyed by the OS. This is what this code would look like.

protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  _extractViewRefs();
  _wireTheUI();
  mMediaPlayer = new MediaPlayer();
  Log.d(TAG, "onCreate: finished");
}

Using this code, every screen rotation would result in one more MediaPlayer object being allocated. You can see this in Android Studio’s profiler. So if you rotated the screen 10 times, you would end up with 10 MediaPlayer objects. 9 of which are not reference-able, but they are still consuming resources, and are in various states, which is not a good thing.

Fragment and setRetainInstance

So outside of using a service and doing this using MediaSession, here’s an approach that works. Creating the MediaPlayer object in Fragment that retains its state between screen orientation changes works very well. You can use this pattern for holding on to all kinds of resources like sockets, threads, etc. Here’s the MainActivity‘s onCreate() method that’s in the GitHub sample.

protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.activity_main);
  _extractViewRefs();
  _wireTheUI();

  FragmentManager fm = getSupportFragmentManager();
  mMediaPlayerFragment = (MediaPlayerFragment) fm.findFragmentByTag(TAG_MEDIA_FRAGMENT);
  if (mMediaPlayerFragment == null) {
    mMediaPlayerFragment = new MediaPlayerFragment();
    fm.beginTransaction().add(mMediaPlayerFragment, TAG_MEDIA_FRAGMENT).commit();
  }

  Log.d(TAG, "onCreate: finished");
}

Here’s the MediaPlayerFragment that holds the actual MediaPlayer object.

public class MediaPlayerFragment extends Fragment {

private int    media_res_id = R.raw.cartoon_telephone_daniel_simion;
private String TAG          = "MediaPlayer.fragment";
private MediaPlayer  mMediaPlayer;
private MainActivity mMainActivity;

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setRetainInstance(true); // re-use this instance across screen rotations
  create();
  load();
  Log.d(TAG, "onCreate: fragment created a media player");
}

@Override
public void onAttach(Context activity) {
  super.onAttach(activity);
  mMainActivity = (MainActivity) activity;
  Log.d(TAG, "onAttach: getting MainActivity instance");
}

@Override
public void onDetach() {
  super.onDetach();
  Log.d(TAG, "onDetach: do nothing");
}
...

The rest of the MediaPlayerFragment class simply wraps a MediaPlayer and exposes it to the MainActivity.

public void create() {
  mMediaPlayer = new MediaPlayer();
  log("new MediaPlayer()");
}

public void play() {
  mMediaPlayer.start();
  log(String.format("play() %s",
                    getResources().getResourceEntryName(media_res_id)));
}

public void pause() {
  mMediaPlayer.pause();
  log("pause()");
}

public void stop() {
  mMediaPlayer.stop();
  mMediaPlayer.prepareAsync();
  log("stop() and prepareAsync()");
}

public void reset() {
  mMediaPlayer.reset();
  load();
  log("reset()");
}

public void load() {
  AssetFileDescriptor afd = this.getResources().openRawResourceFd(media_res_id);
  try {
    mMediaPlayer.setDataSource(afd);
    mMediaPlayer.prepare();
    log("load()");
  } catch (Exception e) {
    log(e.toString());
  }
}

public void release() {
  mMediaPlayer.release();
}

public void log(String msg) {
  mLogMessages.append(msg).append("\n");
  if (mMainActivity != null) mMainActivity.log(mLogMessages.toString());
}

Android Media Resources