Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 099 / 100

Media playback with Media3: patterns, pitfalls and fixes

Audio and video in Android apps.

Expona Admin6 min read

Media playback with Media3: patterns, pitfalls and fixes. Audio and video in Android apps.

ExoPlayer via Media3

ExoPlayer has been the gold standard for media playback in Android for years, and its evolution into the Media3 library represents the modern recommended approach. Media3 unifies ExoPlayer with MediaSession and provides a cleaner API surface that handles many edge cases we used to wrestle with manually.

Start by adding Media3 to your build.gradle:

kotlin
dependencies {
    implementation "androidx.media3:media3-exoplayer:1.1.1"
    implementation "androidx.media3:media3-session:1.1.1"
    implementation "androidx.media3:media3-ui:1.1.1"
}

The core of Media3 is the ExoPlayer interface, which you instantiate through ExoPlayer.Builder. A minimal setup looks like this:

kotlin
val player = ExoPlayer.Builder(context)
    .setAudioAttributes(
        AudioAttributes.Builder()
            .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
            .setUsage(C.USAGE_MEDIA)
            .build(),
        handleAudioFocus = true
    )
    .build()

val mediaItem = MediaItem.Builder()
    .setUri("https://example.com/audio.mp3")
    .build()

player.setMediaItem(mediaItem)
player.prepare()
player.play()

Notice we're setting audio attributes and letting ExoPlayer handle audio focus automatically. This is critical—we'll revisit audio focus management in detail below. The builder pattern gives us fine-grained control without drowning in constructor parameters.

For UI, Media3 provides PlayerView, which is a drop-in replacement for the older ExoPlayerView. It handles the progress bar, controls, fullscreen toggles, and subtitle rendering out of the box:

kotlin
<androidx.media3.ui.PlayerView
    android:id="@+id/player_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:use_controller="true"
    app:show_buffering="true" />

Bind the player to the view in your activity or fragment:

kotlin
binding.playerView.player = player

One pitfall: forgetting to call player.release() in your lifecycle callback. Media3 holds onto hardware resources—leaving them allocated causes battery drain and blocks other apps from playing audio. Always release in onPause() or onDestroy() depending on your use case:

kotlin
override fun onPause() {
    super.onPause()
    player.pause()
}

override fun onDestroy() {
    super.onDestroy()
    player.release()
}

MediaSession for controls

A bare ExoPlayer only responds to UI controls you wire up manually. For a professional app, you need MediaSession—a component that bridges your player to the system's media notification, car integration, wear OS, and headset button events.

Media3's MediaSession is simpler than its predecessor. Create it alongside your ExoPlayer:

kotlin
val mediaSession = MediaSession.Builder(context, player)
    .setCallback(object : MediaSession.Callback {
        override fun onConnect(
            session: MediaSession,
            controller: MediaSession.ControllerInfo
        ): MediaSession.ConnectionResult {
            return MediaSession.ConnectionResult.accept(
                sessionCommands = MediaSession.defaultSessionCommands,
                playerCommands = MediaSession.defaultPlayerCommands
            )
        }
    })
    .build()

The session automatically exposes play, pause, seek, and skip commands to any connected controller—whether that's your app's UI, the system notification, or an external client over Bluetooth. The onConnect callback is your guard: verify the connecting controller is legitimate before granting command access. In most apps, accepting the defaults is safe, but for sensitive use cases (licensed content), you might restrict commands based on the controller's package name.

To see this in action, observe that once you attach a MediaSession to your player, a media notification automatically appears when playback starts. Users can pause, skip, or dismiss from there. Headset button presses trigger the session's callbacks without extra wiring.

A common mistake: creating a new MediaSession every time your activity is recreated. Keep it in a singleton service or ViewModel scoped to longer lifetime. Recreating it drops active controllers and causes the notification to flicker.

Background playback with a service

Many apps need to keep playing audio when the user navigates away or locks their phone. A service keeps the player alive and the app process from being killed by the system.

Create a MediaLibraryService subclass (part of Media3):

kotlin
class PlaybackService : MediaLibraryService() {
    private lateinit var player: ExoPlayer
    private lateinit var mediaSession: MediaSession

    override fun onCreate() {
        super.onCreate()
        player = ExoPlayer.Builder(this)
            .setAudioAttributes(
                AudioAttributes.Builder()
                    .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
                    .setUsage(C.USAGE_MEDIA)
                    .build(),
                handleAudioFocus = true
            )
            .build()

        mediaSession = MediaSession.Builder(this, player)
            .setCallback(sessionCallback)
            .build()

        setMediaNotificationProvider(
            DefaultMediaNotificationProvider(this)
        )
    }

    override fun onGetSession(
        controllerInfo: MediaSession.ControllerInfo
    ): MediaSession = mediaSession

    override fun onDestroy() {
        mediaSession.release()
        player.release()
        super.onDestroy()
    }

    private val sessionCallback = object : MediaSession.Callback {}
}

Declare it in AndroidManifest.xml with the required permissions:

kotlin
<service
    android:name=".PlaybackService"
    android:exported="true"
    android:foregroundServiceType="mediaPlayback">
    <intent-filter>
        <action android:name="androidx.media3.session.MediaSessionService" />
    </intent-filter>
</service>

Also add the permission:

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

From your activity, bind to it or start it with a playback intent. Media3 handles promoting the service to foreground automatically when playback begins. The key win: your app no longer dies when the user leaves, and the system notifies the user that audio is playing (satisfying Android's notification transparency requirements).

A pitfall here is not handling the case where the notification is dismissed. By default, Media3 stops the service, which stops playback. For a podcast app or music player where background playback is core, you may want to adjust notification behavior or restart on swipe-away.

Handle audio focus

Audio focus is the Android mechanism for arbitrating which app gets to play sound. When a phone call arrives or another app starts playing, your player should pause or duck (lower volume). Ignore this and your app produces a cacophony of competing sounds.

When you set handleAudioFocus = true on the ExoPlayer builder, it handles focus automatically. But understanding what's happening under the hood helps debug edge cases.

Media3's AudioFocusManager listens for focus loss events. On permanent loss (another app claimed focus), it pauses. On transient loss (a call, notification sound), it pauses or ducks depending on your configuration. When focus is regained, it resumes.

To customize this behavior, implement a Player.Listener:

kotlin
player.addListener(object : Player.Listener {
    override fun onPlaybackStateChanged(@Player.State state: Int) {
        when (state) {
            Player.STATE_READY -> {
                // Playback is ready
            }
            Player.STATE_ENDED -> {
                // Playback finished
            }
            Player.STATE_IDLE -> {
                // Player is idle
            }
        }
    }

    override fun onPlayWhenReadyChanged(
        playWhenReady: Boolean,
        reason: @Player.PlayWhenReadyChangeReason Int
    ) {
        if (!playWhenReady) {
            when (reason) {
                Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS -> {
                    // Handle audio focus loss
                }
                Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST -> {
                    // User paused
                }
            }
        }
    }
})

The reason parameter tells you why playback was paused—crucial for analytics and UX decisions. If audio focus was lost, you might show a transient message; if the user tapped pause, you don't.

A common pitfall: apps that don't request audio focus at all. Before ExoPlayer plays, it should call AudioManager.requestAudioFocus(). Media3 does this for you, but if you're building a custom player or integrating older code, remember it. Without it, your app plays alongside everything else, violating platform expectations.

Common mistakes

  • Not releasing the player: Leaving a player alive after the activity is destroyed locks audio hardware. Always call player.release() in onDestroy().
  • Recreating MediaSession on configuration changes: This drops controllers and causes notification flicker. Use a ViewModel or service to keep the session alive across activity recreation.
  • Ignoring audio focus changes: Players that don't pause on focus loss create conflicts with the system and other apps. Let Media3's automatic handling work, or implement a Player.Listener for custom logic.
  • Not handling headset disconnection: When headphones are unplugged mid-playback, AudioManager.ACTION_AUDIO_BECOMING_NOISY broadcasts. By default, Media3 pauses; make sure your app respects this.
  • Missing foreground service permission on Android 12+: Background playback requires the FOREGROUND_SERVICE_MEDIA_PLAYBACK permission. Without it, the service crashes on start.

Key takeaways

  • Media3's ExoPlayer is the recommended playback library; use ExoPlayer.Builder with audio attributes and let it handle audio focus automatically.
  • Attach a MediaSession to enable system notifications, headset controls, and car integration without manual wiring.
  • Background playback requires a MediaLibraryService; Media3 promotes it to foreground automatically and handles lifecycle cleanly.
  • Audio focus is mandatory for system harmony; Media3 handles it, but understand why your player pauses via the Player.Listener.onPlayWhenReadyChanged reason parameter.
  • Always release the player on lifecycle events to free hardware resources and prevent battery drain.
  • Test on real devices with headsets, car systems, and notification interactions—emulators mask many real-world quirks.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles