Building a Media Player App with Jetpack Media3 in Jetpack compose

Abhishek Pathak
9 min readApr 11, 2024

Jetpack Media3 offers a robust framework for building media player apps on Android, with ExoPlayer as the default implementation of the Player interface. ExoPlayer simplifies the development process by providing comprehensive features for video and audio playback, including support for playlists, various streaming formats, DRM protection, and ad insertion. This article will guide you through the process of creating a basic media player app with notification support using Media3 ExoPlayer and Jetpack Compose.

How is Media3 different from other media or media2 libraries?

Media3 stands out from other playback libraries because it eliminates the need for connectors between components. With Media3, the MediaSession class can accept any class that implements the Player interface, including UI components like ExoPlayer and MediaController. This streamlined approach simplifies interaction between different parts of the app, making the development process much easier.

What is MediaSessionService?

To enable background playback, you’ll want to create a service that houses both the Player and MediaSession. Essentially, this allows media to continue playing even when your app is not actively visible.

Your service should extend MediaSessionService, which enables external clients like Google Assistant to manage playback even when the app is running in the background.

By utilizing MediaSessionService, the media session operates independently from the app’s activity, ensuring seamless playback continuity. This service runs as long as it’s bound to a MediaController, a crucial component responsible for interacting with the MediaSession hosted by the MediaSessionService. MediaController is typically used in the UI to send commands to the player.

The lifecycle of a media session methods:

  1. onCreate(): This method is called when the first controller is about to connect, marking the creation and start of the service. It’s the ideal place to instantiate the Player and MediaSession. The MediaSessionService is then able to operate independently from the app’s activity, enabling external clients like Google Assistant or system media controls to discover and interact with it.
  2. onTaskRemoved(Intent): Triggered when the app is removed from recent tasks. If playback is ongoing, the service can be kept running in the foreground. However, if the player is paused and the service is not in the foreground, it should be stopped to conserve resources.
  3. onDestroy(): Invoked when the service is being stopped. This is where all resources, including the player and session, should be released to ensure proper cleanup.
  4. onGetSession(): Called when a MediaController is created, this method returns a MediaSession for the controller to connect to. It can also return null to reject the connection request.

Additionally, when a MediaController binds to the MediaSessionService, the onBind() method is executed, leading to the subsequent invocation of onGetSession() within onBind().

Finally, the service will be destroyed under two conditions: when the MediaSession is released or when no controller is bound to the service while it’s in the background.

Moving for implementation

  1. Getting Started

To begin, add dependencies on the ExoPlayer, UI, and Common modules of Jetpack Media3 to your project. Ensure you have the necessary dependencies by adding the following lines to your build.gradle file:

implementation "androidx.media3:media3-exoplayer:1.3.1"
implementation "androidx.media3:media3-ui:1.3.1"
implementation "androidx.media3:media3-common:1.3.1"
implementation "androidx.media3:media3-session:1.3.1"

Setting up Dagger Hilt module for providing ExoPlayer instances to ViewModel components.

/**
* Module responsible for providing ExoPlayer instances to ViewModel components.
*/
@Module
@InstallIn(ViewModelComponent::class)
class MediaModule {

/**
* Provides a singleton instance of ExoPlayer scoped to ViewModel.
* @param application The application context used to build ExoPlayer instance.
* @return A singleton instance of ExoPlayer.
*/
@Provides
@ViewModelScoped
fun provideExoPlayer(application: Application): ExoPlayer =
ExoPlayer.Builder(application).build()
}

Create Notification Manager to handle notification
The MediaNotificationManager class manages the audio playback notification, displaying track details and controls during playback. It sets up the notification with ExoPlayer's PlayerNotificationManager, handles visibility, and loads track metadata asynchronously. This ensures a smooth user experience with minimal setup and interaction.

/**
* A wrapper class for ExoPlayer's PlayerNotificationManager.
* It sets up the notification shown to the user during audio playback and provides track metadata,
* such as track title and icon image.
* @param context The context used to create the notification.
* @param sessionToken The session token used to build MediaController.
* @param player The ExoPlayer instance.
* @param notificationListener The listener for notification events.
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class MediaNotificationManager(
private val context: Context,
sessionToken: SessionToken,
private val player: Player,
notificationListener: PlayerNotificationManager.NotificationListener
) {
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
private val notificationManager: PlayerNotificationManager

init {

val mediaController = MediaController.Builder(context, sessionToken).buildAsync()

notificationManager = PlayerNotificationManager.Builder(
context,
NOW_PLAYING_NOTIFICATION_ID,
NOW_PLAYING_CHANNEL_ID
)
.setChannelNameResourceId(R.string.media_notification_channel)
.setChannelDescriptionResourceId(R.string.media_notification_channel_description)
.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
.setNotificationListener(notificationListener)
.setSmallIconResourceId(R.drawable.baseline_play_circle_24)
.build()
.apply {
setPlayer(player)
setUseRewindAction(true)
setUseFastForwardAction(true)
setUseRewindActionInCompactView(true)
setUseFastForwardActionInCompactView(true)
setUseRewindActionInCompactView(true)
setUseFastForwardActionInCompactView(true)
}

}

/**
* Hides the notification.
*/
fun hideNotification() {
notificationManager.setPlayer(null)
}

/**
* Shows the notification for the given player.
* @param player The player instance for which the notification is shown.
*/
fun showNotificationForPlayer(player: Player) {
notificationManager.setPlayer(player)
}

private inner class DescriptionAdapter(private val controller: ListenableFuture<MediaController>) :
PlayerNotificationManager.MediaDescriptionAdapter {

var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null

override fun createCurrentContentIntent(player: Player): PendingIntent? =
controller.get().sessionActivity

override fun getCurrentContentText(player: Player) =
""

override fun getCurrentContentTitle(player: Player) =
controller.get().mediaMetadata.title.toString()

override fun getCurrentLargeIcon(
player: Player,
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
val iconUri = controller.get().mediaMetadata.artworkUri
return if (currentIconUri != iconUri || currentBitmap == null) {

// Cache the bitmap for the current song so that successive calls to
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = iconUri
serviceScope.launch {
currentBitmap = iconUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
} else {
currentBitmap
}
}

private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
// Block on downloading artwork.
Glide.with(context).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(uri)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
.get()
}
}
}
}

/**
* The size of the large icon for the notification in pixels.
*/
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px

/**
* The channel ID for the notification.
*/
const val NOW_PLAYING_CHANNEL_ID = "media.NOW_PLAYING"

/**
* The notification ID.
*/
const val NOW_PLAYING_NOTIFICATION_ID = 0xb339 // Arbitrary number used to identify our notification

Create ViewModel to handle logic for media3 notification and mediaItem

The MediaViewModel class manages audio playback and UI states using ExoPlayer. It initializes the player, sets up playlists, and handles player actions like play, pause, next, and rewind. It also manages the media session, notification display, and updates UI states accordingly. Finally, it listens for player events, synchronizes UI flows, and provides feedback on playback errors.

@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@HiltViewModel
class MediaViewModel @Inject constructor(
val player: ExoPlayer
) : ViewModel() {

private val _currentPlayingIndex = MutableStateFlow(0)
val currentPlayingIndex = _currentPlayingIndex.asStateFlow()

private val _totalDurationInMS = MutableStateFlow(0L)
val totalDurationInMS = _totalDurationInMS.asStateFlow()

private val _isPlaying = MutableStateFlow(false)
val isPlaying = _isPlaying.asStateFlow()

val uiState: StateFlow<PlayerUIState> =
MutableStateFlow(PlayerUIState.Tracks(playlist)).stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
initialValue = PlayerUIState.Loading
)

private lateinit var notificationManager: MediaNotificationManager

protected lateinit var mediaSession: MediaSession
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)


private var isStarted = false

fun preparePlayer(context: Context) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build()

player.setAudioAttributes(audioAttributes, true)
player.repeatMode = Player.REPEAT_MODE_ALL

player.addListener(playerListener)

setupPlaylist(context)
}

private fun setupPlaylist(context: Context) {

val videoItems: ArrayList<MediaSource> = arrayListOf()
playlist.forEach {

val mediaMetaData = MediaMetadata.Builder()
.setArtworkUri(Uri.parse(it.teaserUrl))
.setTitle(it.title)
.setAlbumArtist(it.artistName)
.build()

val trackUri = Uri.parse(it.audioUrl)
val mediaItem = MediaItem.Builder()
.setUri(trackUri)
.setMediaId(it.id)
.setMediaMetadata(mediaMetaData)
.build()
val dataSourceFactory = DefaultDataSource.Factory(context)

val mediaSource =
ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)

videoItems.add(
mediaSource
)
}

onStart(context)

player.playWhenReady = false
player.setMediaSources(videoItems)
player.prepare()
}

fun updatePlaylist(action: ControlButtons) {
when (action) {
ControlButtons.Play -> if (player.isPlaying) player.pause() else player.play()
ControlButtons.Next -> player.seekToNextMediaItem()
ControlButtons.Rewind -> player.seekToPreviousMediaItem()
}
}

fun updatePlayerPosition(position: Long) {
player.seekTo(position)
}

fun onStart(context: Context) {
if (isStarted) return

isStarted = true

// Build a PendingIntent that can be used to launch the UI.
val sessionActivityPendingIntent =
context.packageManager?.getLaunchIntentForPackage(context.packageName)
?.let { sessionIntent ->
PendingIntent.getActivity(
context,
SESSION_INTENT_REQUEST_CODE,
sessionIntent,
PendingIntent.FLAG_IMMUTABLE
)
}

// Create a new MediaSession.
mediaSession = MediaSession.Builder(context, player)
.setSessionActivity(sessionActivityPendingIntent!!).build()

notificationManager =
MediaNotificationManager(
context,
mediaSession.token,
player,
PlayerNotificationListener()
)


notificationManager.showNotificationForPlayer(player)
}

/**
* Destroy audio notification
*/
fun onDestroy() {
onClose()
player.release()
}

/**
* Close audio notification
*/
fun onClose() {
if (!isStarted) return

isStarted = false
mediaSession.run {
release()
}

// Hide notification
notificationManager.hideNotification()

// Free ExoPlayer resources.
player.removeListener(playerListener)
}

/**
* Listen for notification events.
*/
private inner class PlayerNotificationListener :
PlayerNotificationManager.NotificationListener {
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
ongoing: Boolean
) {

}

override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {

}
}

/**
* Listen to events from ExoPlayer.
*/
private val playerListener = object : Player.Listener {

override fun onPlaybackStateChanged(playbackState: Int) {
Log.d(TAG, "onPlaybackStateChanged: ${playbackState}")
super.onPlaybackStateChanged(playbackState)
syncPlayerFlows()
when (playbackState) {
Player.STATE_BUFFERING,
Player.STATE_READY -> {
notificationManager.showNotificationForPlayer(player)
}

else -> {
notificationManager.hideNotification()
}
}
}

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaMetadata?.title}")
super.onMediaItemTransition(mediaItem, reason)
syncPlayerFlows()
}

override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged: ${isPlaying}")
super.onIsPlayingChanged(isPlaying)
_isPlaying.value = isPlaying
}

override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
Log.e(TAG, "Error: ${error.message}")
}
}

private fun syncPlayerFlows() {
_currentPlayingIndex.value = player.currentMediaItemIndex
_totalDurationInMS.value = player.duration.coerceAtLeast(0L)
}
}

Requesting notification permissions as you need it from user to enable

/**
* Composable function to request notification permissions.
* This composable requests notification permissions using the `ActivityResultContracts.RequestPermission`
* contract. It updates the state based on the permission result, allowing the caller to react accordingly.
* @throws SecurityException if the permission is denied.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
fun RequestNotificationPermissions() {
// State to track whether notification permission is granted
var hasNotificationPermission by remember { mutableStateOf(false) }

// Request notification permission and update state based on the result
val permissionResult = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { hasNotificationPermission = it }
)

// Request notification permission when the component is launched
LaunchedEffect(key1 = true) {
permissionResult.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

also add these permissions into manifest file

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

Connecting to Your UI

Link your UI components with the ViewModel to update the UI based on the player state. Use the rememberViewModel function to access the ViewModel from your Composable functions.

    PlayerControlsView(
currentTrackImage = (uiState as PlayerUIState.Tracks).items[currentTrackState].teaserUrl,
totalDuration = totalDurationState,
currentPosition = currentPositionState,
isPlaying = isPlayingState,
navigateTrack = { action -> viewModel.updatePlaylist(action) },
seekPosition = { position -> viewModel.updatePlayerPosition((position * 1000).toLong()) }
)
Notification demo using media3

To wrap up, MediaSessionService is a key player in making apps more user-friendly. In this article, we’ve delved into its structure and how it fits into Jetpack Compose. I believe MediaSessionService not only streamlines background media control but also paves the way for more engaging app designs.

here is the full code implementation

here is my prev article about notification and types in details

Photo by Frugal Flyer on Unsplash

If you’re looking to integrate mediaSessionService for creating a music player, give this a try.

Photo by Alexas_Fotos on Unsplash

Thank you for reading my article. I really appreciate your response.

Clap if this article helps you. If I got something wrong, please comment for improve.
let’s connect on
Linkedin , GitHub

References

--

--