Playing local media files and stream media on Android using ExoPlayer
Tags: android, kotlin, exoplayer, player
Published: 2019-11-07 22:05:57

We have 2 options if you want to play media files on Android: 
- Android’s [`MediaPlayer`](https://developer.android.com/reference/android/media/MediaPlayer) APIs or 
- [`ExoPlayer`](http://exoplayer.dev)

`ExoPlayer` is easier to work with and it supports features currently not supported by `MediaPlayer` APIs. The main downside in using `ExoPlayer` according to its docs is that:
>For audio only playback on some devices, ExoPlayer may consume significantly more battery than MediaPlayer. [(1)](https://exoplayer.dev/battery-consumption.html)

---

Before we start coding let us think about the main cases we want to handle:
- The first obvious case is to maintain continuous playback regardless of whether the app is in the foreground or the background. Which translates into using a [`Service`](https://developer.android.com/guide/components/services).
- And because of the new [restrictions on running background services](https://developer.android.com/about/versions/oreo/background.html) that were introduced in API level 26 and higher we need to make sure that we use a foreground `Service` otherwise our `Service` could get killed by the system unexpectedly.

> A foreground `Service` performs some operation that is noticeable to the user. For example, an audio app would use a foreground `Service` to play an audio track. Foreground `Service`s must display a Notification. Foreground services continue running even when the user isn't interacting with the app. [(2)](https://developer.android.com/guide/components/services)

- Given that we will use a foreground `Service` to manage the playback we need to tie it with a `Notification`. We can build one but we do not have to, `ExoPlayer`'s `PlayerNotificationManager` can create one for us in [a few lines of code](https://medium.com/google-exoplayer/playback-notifications-with-exoplayer-a2f1a18cf93b) and it will come with a familiar design to our users.

- Although Android can play media from multiple different sources (say different apps playing audio simultaneously), in most cases that will not be a very good end-user experience. So Android introduced this concept of [audio focus](https://medium.com/google-exoplayer/easy-audio-focus-with-exoplayer-a2dcbbe4640e), only one app can hold audio focus at a time. With [a few lines of code ExoPlayer can handle audio focus](https://developer.android.com/guide/topics/media-apps/audio-focus) for us.

- Another case we would need to worry about is allowing clients and connected devices (like Google Assistant, Android Auto, Android TV, and Android Wear) to manage the media being played. Luckily we can delegate that to [Android's `MediaSession` API](https://developer.android.com/guide/topics/media-apps/working-with-a-media-session.html).
[![Android Auto](https://miro.medium.com/max/576/1*xgjqZoP67CVMAGCtOLhCPA.png)](https://medium.com/google-exoplayer/the-mediasession-extension-for-exoplayer-82b9619deb2d)


---

Now that we know the desired behavior, let us start coding.

1. Create a `LifecycleService` or a regular `Service` and assuming we want to play a single media file for simplicity, pass in its title, uri, and start position.

```kotlin
class AudioService : LifecycleService() {

    companion object {
        @MainThread
        fun newIntent(context: Context, title: String, uriString: String, startPosition: Long) = Intent(context, AudioService::class.java).apply {
            putExtra(ARG_TITLE, title)
            putExtra(ARG_URI, Uri.parse(uriString))
            putExtra(ARG_START_POSITION, episodeDetails.listened?.startPosition)
        }
    }
...
```

2. Initialize the `ExoPlayer` and move the `Service` to the foreground when playing starts and back to the background when playback stops for whatever reason.
```kotlin
private var episodeTitle: String? = null

private lateinit var exoPlayer: SimpleExoPlayer

private var playerNotificationManager: PlayerNotificationManager? = null
private var mediaSession: MediaSessionCompat? = null
private var mediaSessionConnector: MediaSessionConnector? = null

private const val PLAYBACK_CHANNEL_ID = "playback_channel"
private const val PLAYBACK_NOTIFICATION_ID = 1

private const val ARG_URI = "uri_string"
private const val ARG_TITLE = "title"
private const val ARG_START_POSITION = "start_position"


override fun onCreate() {
    super.onCreate()

    exoPlayer = ExoPlayerFactory.newSimpleInstance(this, DefaultTrackSelector())
    val audioAttributes = AudioAttributes.Builder()
            .setUsage(C.USAGE_MEDIA)
            .setContentType(C.CONTENT_TYPE_SPEECH)
            .build()
    exoPlayer.setAudioAttributes(audioAttributes, true)

    // Setup notification and media session.
    playerNotificationManager = PlayerNotificationManager.createWithNotificationChannel(
            applicationContext,
            PLAYBACK_CHANNEL_ID,
            R.string.playback_channel_name,
            PLAYBACK_NOTIFICATION_ID,
            object : PlayerNotificationManager.MediaDescriptionAdapter {
                override fun getCurrentContentTitle(player: Player): String {
                    // return title
                }

                @Nullable
                override fun createCurrentContentIntent(player: Player): PendingIntent? = PendingIntent.getActivity(
                        applicationContext,
                        0,
                        Intent(applicationContext, MainActivity::class.java),
                        PendingIntent.FLAG_UPDATE_CURRENT)

                @Nullable
                override fun getCurrentContentText(player: Player): String? {
                    return null
                }

                @Nullable
                override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? {
                    return getBitmapFromVectorDrawable(applicationContext, R.drawable.vd_app_icon)
                }
            },
            object : PlayerNotificationManager.NotificationListener {
                override fun onNotificationStarted(notificationId: Int, notification: Notification?) {
                    startForeground(notificationId, notification)
                }

                override fun onNotificationCancelled(notificationId: Int) {
                    _playerStatusLiveData.value = PlayerStatus.Cancelled(episodeId)

                    stopSelf()
                }

                override fun onNotificationPosted(notificationId: Int, notification: Notification?, ongoing: Boolean) {
                    if (ongoing) {
                        // Make sure the service will not get destroyed while playing media.
                        startForeground(notificationId, notification)
                    } else {
                        // Make notification cancellable.
                        stopForeground(false)
                    }
                }
            }
    ).apply {
        // Omit skip previous and next actions.
        setUseNavigationActions(false)

        // Add stop action.
        setUseStopAction(true)

        setPlayer(exoPlayer)
    }

    ...
}

@MainThread
private fun getBitmapFromVectorDrawable(context: Context, @DrawableRes drawableId: Int): Bitmap? {
    return ContextCompat.getDrawable(context, drawableId)?.let {
        val drawable = DrawableCompat.wrap(it).mutate()

        val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        drawable.setBounds(0, 0, canvas.width, canvas.height)
        drawable.draw(canvas)

        bitmap
    }
}
```

3. With the next few lines, we can allow Google assistant to manage playback.
```kotlin
private const val MEDIA_SESSION_TAG = "hello_world_media"

override fun onCreate() {
    super.onCreate()
    
    ...
    
    mediaSession = MediaSessionCompat(applicationContext, MEDIA_SESSION_TAG).apply {
        isActive = true
    }
    playerNotificationManager?.setMediaSessionToken(mediaSession?.sessionToken)

    ...
}
```

4. We can also monitor the playback change events.
```kotlin
override fun onCreate() {
    super.onCreate()

    ...

    // Monitor ExoPlayer events.
    exoPlayer.addListener(PlayerEventListener())

    ...
}

private inner class PlayerEventListener : Player.EventListener {

    override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
        if (playbackState == Player.STATE_READY) {
            if (exoPlayer.playWhenReady) {
                // In Playing state
            } else {
                // In Paused state
            }
        } else if (playbackState == Player.STATE_ENDED) {
            // In Ended state
        }
    }

    override fun onPlayerError(e: ExoPlaybackException?) {
        // On error
    }

}
```

5. Now to the actual play, pause and resume code.
```kotlin
@MainThread
fun play(uri: Uri, startPosition: Long, playbackSpeed: Float? = null) {
    val userAgent = Util.getUserAgent(applicationContext, BuildConfig.APPLICATION_ID)
    val mediaSource = ExtractorMediaSource(
            uri,
            DefaultDataSourceFactory(applicationContext, userAgent),
            DefaultExtractorsFactory(),
            null,
            null)

    val haveStartPosition = startPosition != C.POSITION_UNSET.toLong()
    if (haveStartPosition) {
        exoPlayer.seekTo(startPosition)
    }

    exoPlayer.prepare(mediaSource, !haveStartPosition, false)
    exoPlayer.playWhenReady = true
}

@MainThread
fun resume() {
    exoPlayer.playWhenReady = true
}

@MainThread
fun pause() {
    exoPlayer.playWhenReady = false
}
```

6. Lastly and very importantly​, let us clean after ourselves when we are done.
```kotlin
override fun onDestroy() {
    mediaSession?.release()
    mediaSessionConnector?.setPlayer(null)
    playerNotificationManager?.setPlayer(null)

    exoPlayer.release()

    super.onDestroy()
}
```

---

Checkout the [full and up to date code here](https://github.com/SoftwareEngineeringDaily/software-engineering-daily-android/blob/master/app/src/main/java/com/koalatea/sedaily/feature/player/AudioService.kt?source=post_page---------------------------#L58).