We have 2 options if you want to play media files on Android:
- Android’s
MediaPlayer
APIs or ExoPlayer
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 ourService
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 foregroundService
to play an audio track. ForegroundService
s 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 aNotification
. We can build one but we do not have to,ExoPlayer
'sPlayerNotificationManager
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.
Now that we know the desired behavior, let us start coding.
- Create a
LifecycleService
or a regularService
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)
}
}
...
- Initialize the
ExoPlayer
and move theService
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
}
}
- 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)
...
}
- 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
}
}
- 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
}
- 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()
}
Checkout the full and up to date code here.
Top comments (0)