Playing local media files and stream media on Android using ExoPlayer

mgazar_

Mostafa Gazar

Posted on November 7, 2019

Playing local media files and stream media on Android using ExoPlayer

We have 2 options if you want to play media files on Android:

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)


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.
  • And because of the new restrictions on running background services 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 Services must display a Notification. Foreground services continue running even when the user isn't interacting with the app. (2)

  • 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 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, only one app can hold audio focus at a time. With a few lines of code ExoPlayer can handle 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.
    Android Auto


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.
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)
        }
    }
...
Enter fullscreen mode Exit fullscreen mode
  1. Initialize the ExoPlayer and move the Service to the foreground when playing starts and back to the background when playback stops for whatever reason.
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
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. With the next few lines, we can allow Google assistant to manage playback.
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)

    ...
}
Enter fullscreen mode Exit fullscreen mode
  1. We can also monitor the playback change events.
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
    }

}
Enter fullscreen mode Exit fullscreen mode
  1. Now to the actual play, pause and resume code.
@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
}
Enter fullscreen mode Exit fullscreen mode
  1. Lastly and very importantly​, let us clean after ourselves when we are done.
override fun onDestroy() {
    mediaSession?.release()
    mediaSessionConnector?.setPlayer(null)
    playerNotificationManager?.setPlayer(null)

    exoPlayer.release()

    super.onDestroy()
}
Enter fullscreen mode Exit fullscreen mode

Checkout the full and up to date code here.

💖 💪 🙅 🚩
mgazar_
Mostafa Gazar

Posted on November 7, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related