ExoPlayer (Java)

Android ships a built-in MediaPlayer class, but it is limited and clunky for anything beyond the simplest playback. ExoPlayer (now distributed as part of Media3) is Google's own recommended replacement — it is more flexible, easier to customize, and actively maintained, while MediaPlayer has stayed mostly frozen for years.

In RecipeBox we will use ExoPlayer for a very ordinary feature: some recipes include a short demonstration video (say, "how to fold the dumpling" or "knife technique for dicing onions") stored alongside the Recipe's photoUri. Tapping a "Watch demo" button on the recipe detail screen should play that clip inline.

Adding the Dependency

ExoPlayer is added like any other library, in the app module's build.gradle:

dependencies {
    implementation 'androidx.media3:media3-exoplayer:1.3.1'
    implementation 'androidx.media3:media3-ui:1.3.1'
}
Note: media3-ui gives us PlayerView, the ready-made widget that draws the video surface plus default playback controls (play/pause, seek bar, fullscreen). You rarely need to build custom playback controls from scratch.

Adding PlayerView to the Layout

The recipe detail screen gets a PlayerView where the video surface lives:

<androidx.media3.ui.PlayerView
    android:id="@+id/playerView"
    android:layout_width="match_parent"
    android:layout_height="220dp"
    app:use_controller="true"
    app:show_shuffle_button="false" />

Building the Player and Playing a MediaItem

In the recipe detail Activity, we create an ExoPlayer instance, attach it to the PlayerView, then hand it a MediaItem built from the recipe's demo video URI:

// RecipeDetailActivity.java
public class RecipeDetailActivity extends AppCompatActivity {

    private PlayerView playerView;
    private ExoPlayer player;
    private Recipe recipe; // loaded earlier, has demoVideoUri

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_recipe_detail);

        playerView = findViewById(R.id.playerView);
    }

    private void initializePlayer() {
        player = new ExoPlayer.Builder(this).build();
        playerView.setPlayer(player);

        MediaItem mediaItem = MediaItem.fromUri(recipe.getDemoVideoUri());
        player.setMediaItem(mediaItem);
        player.prepare();
        player.play();
    }
}
Explanation:
  • ExoPlayer.Builder(this).build() creates the player engine itself.
  • playerView.setPlayer(player) connects the engine to the on-screen surface and controls.
  • MediaItem.fromUri(...) wraps a local file, raw resource, or remote URL — ExoPlayer doesn't care which, as long as the URI is valid.
  • prepare() loads enough of the media to start playback; play() actually starts it. They are deliberately separate steps.

Releasing the Player in the Lifecycle

An ExoPlayer instance holds onto real system resources — decoders, surfaces, sometimes a background thread. If you never call release(), those resources stay allocated even after the Activity is gone, and repeatedly opening/closing the recipe detail screen without releasing will eventually exhaust decoder resources or leak memory. Always tie release() to a lifecycle callback:

@Override
protected void onStart() {
    super.onStart();
    initializePlayer();
}

@Override
protected void onStop() {
    super.onStop();
    if (player != null) {
        player.release();
        player = null;
    }
}
Why this matters: Forgetting release() is one of the most common ExoPlayer mistakes. Unlike a plain object, the player is backed by native decoder resources the garbage collector does not reliably reclaim promptly — you must release it explicitly, the same way you close a file stream or a database cursor.

Listening for Playback State

To drive a custom play/pause icon or show a loading spinner, attach a Player.Listener:

player.addListener(new Player.Listener() {
    @Override
    public void onPlaybackStateChanged(int state) {
        if (state == Player.STATE_BUFFERING) {
            progressBar.setVisibility(View.VISIBLE);
        } else if (state == Player.STATE_READY) {
            progressBar.setVisibility(View.GONE);
        }
    }

    @Override
    public void onIsPlayingChanged(boolean isPlaying) {
        playPauseButton.setImageResource(
            isPlaying ? R.drawable.ic_pause : R.drawable.ic_play);
    }
});
Explanation: onPlaybackStateChanged reports buffering/ready/ended states; onIsPlayingChanged is the simplest hook for toggling a play/pause icon without tracking state yourself.

Beyond RecipeBox: Adaptive Streaming

RecipeBox's demo clips are short, simple video files — a single MediaItem.fromUri() call is all we need. For apps that stream longer video at varying quality levels depending on network conditions, ExoPlayer has built-in support for adaptive streaming formats like HLS and DASH, automatically switching between bitrates as bandwidth changes. That's a deeper topic on its own and not something RecipeBox needs, but it's worth knowing ExoPlayer already handles it if a future feature calls for it.

Quick Reference

CallPurpose
new ExoPlayer.Builder(context).build()Create the player engine
playerView.setPlayer(player)Attach engine to the visible surface/controls
MediaItem.fromUri(uri)Describe what to play
setMediaItem() / prepare() / play()Queue, load, and start playback
addListener(...)React to state/playing changes for custom UI
release()Free decoder resources — call in onStop()/onDestroy()
Video playback covers RecipeBox's on-screen media needs. Next, we'll look at reading raw device sensors — starting with the accelerometer — in Sensors (Accelerometer, Gyroscope).