Build Music Player with Jetpack Compose + Media3 ExoPlayer

Ali Talha Çoban
8 min readNov 23, 2023

This project combines modern Android development practices by using using Media3 ExoPlayer with Jetpack Compose to create an interactive and visually appealing music player application .

This app will be quite simple and plain. After this project you will be able to use it in more complex projects.

Prerequisites

For this project, I’ve added a sound files to play. However, in real projects, you most likely to make some API calls to fetch song list to play. So, this is just to put it simply. The music files are included in my GitHub repository, but of course you can add any music resource file you want. You can access the source code of this project and the image files used in the project from the link below.

Firts, create an android resource directory under the res directory and then put your music files in it as follows.

Media3 ExoPlayer: A Robust Media Playback Solution

Media3 ExoPlayer, an extension of the widely-used ExoPlayer library, brings advanced media playback capabilities to Android applications. In this project, Media3 ExoPlayer is employed to handle the playback of music tracks. The ExoPlayer’s flexibility allows for the creation of a music player with functionalities such as seeking, pausing, and resuming playback. Add the library as below

implementation ("com.google.accompanist:accompanist-systemuicontroller:0.33.2-alpha")
implementation ("androidx.media3:media3-exoplayer:1.1.1")

Prepare Media

Create ExoPlayer object as follows

val player = ExoPlayer.Builder(this).build()

We have a data class that represents a song in the play list

data class Music(
val name: String,
val artist: String,
val music: Int,
val cover: Int,
)

And define a function that retruns the play list.

private fun getPlayList(): List<Music> {
return listOf(
Music(
name = "Master Of Puppets",
artist = "Metallica",
cover = R.drawable.master_of_puppets_album_cover,
music = R.raw.master_of_puppets
),
Music(
name = "Everyday Normal Guy 2",
artist = "Jon Lajoie",
cover = R.drawable.everyday_normal_guy_2_album_cover,
music = R.raw.everyday_normal_guy_2
),
Music(
name = "Lose Yourself",
artist = "Eminem",
cover = R.drawable.lose_yourself_album_cover,
music = R.raw.lose_yourself
),
Music(
name = "Crazy",
artist = "Gnarls Barkley",
cover = R.drawable.crazy_album_cover,
music = R.raw.crazy
),
Music(
name = "Till I Collapse",
artist = "Eminem",
cover = R.drawable.till_i_collapse_album_cover,
music = R.raw.till_i_collapse
),
)
}

To add this play list to our player, for each item, the following code snippet constructs a URI for the corresponding audio file using the package name of the app and the file path specified in it.music. It then creates a MediaItem from this URI and adds it to the ExoPlayer instance.

LaunchedEffect(Unit) {
playList.forEach {
val path = "android.resource://" + packageName + "/" + it.music
val mediaItem = MediaItem.fromUri(Uri.parse(path))
player.addMediaItem(mediaItem)
}
}
player.prepare()

States for Song Control

Create our states to tract the media player actions and updates the states to use in neccesery areas such as Slider, album cover animation.


val isPlaying = remember {
mutableStateOf(false)
}

val currentPosition = remember {
mutableLongStateOf(0)
}

val sliderPosition = remember {
mutableLongStateOf(0)
}

val totalDuration = remember {
mutableLongStateOf(0)
}


LaunchedEffect(key1 = player.currentPosition, key2 = player.isPlaying) {
delay(1000)
currentPosition.longValue = player.currentPosition
}

LaunchedEffect(currentPosition.longValue) {
sliderPosition.longValue = currentPosition.longValue
}

LaunchedEffect(player.duration) {
if (player.duration > 0) {
totalDuration.longValue = player.duration
}
}

Visualizing Album Cover

One of the most captivating elements of this music player is the album cover animation. The VinylAlbumCoverAnimation and VinylAlbumCover Composable functions handle the rotation effect as the user plays a song. We need a background image for the vinyl-themed UI.

@Composable
fun VinylAlbumCover(
modifier: Modifier = Modifier,
rotationDegrees: Float = 0f,
painter: Painter
) {

val roundedShape = object : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val p1 = Path().apply {
addOval(Rect(4f, 3f, size.width - 1, size.height - 1))
}
val thickness = size.height / 2.10f
val p2 = Path().apply {
addOval(
Rect(
thickness,
thickness,
size.width - thickness,
size.height - thickness
)
)
}
val p3 = Path()
p3.op(p1, p2, PathOperation.Difference)

return Outline.Generic(p3)
}
}

Box(
modifier = modifier
.aspectRatio(1.0f)
.clip(roundedShape)
) {

Image(
modifier = Modifier
.fillMaxSize()
.rotate(rotationDegrees),
painter = painterResource(id = R.drawable.vinyl_background),
contentDescription = "vinyl background"
)

Image(
modifier = Modifier
.fillMaxSize(0.5f)
.rotate(rotationDegrees)
.aspectRatio(1.0f)
.align(Alignment.Center)
.clip(roundedShape),
painter = painter,
contentDescription = "song album cover"
)
}
}

I created a variable roundedShape to create a custom outline for a rounded shape. It makes album cover images in the shape of a circle. This image is clipped to the custom shape, rotated, and aligned at the center with a size of half the available space. And then, we put this circle image on the vinyl background image. It takes the entire size of the box and rotates it based on the rotationDegrees.

To animate this combined album cover we use the following Composable function

@Composable
fun VinylAlbumCoverAnimation(
modifier: Modifier = Modifier,
isSongPlaying: Boolean = true,
painter: Painter
) {
var currentRotation by remember {
mutableFloatStateOf(0f)
}

val rotation = remember {
Animatable(currentRotation)
}

LaunchedEffect(isSongPlaying) {
if (isSongPlaying) {
rotation.animateTo(
targetValue = currentRotation + 360f,
animationSpec = infiniteRepeatable(
animation = tween(3000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
) {
currentRotation = value
}
} else {
if (currentRotation > 0f) {
rotation.animateTo(
targetValue = currentRotation + 50,
animationSpec = tween(
1250,
easing = LinearOutSlowInEasing
)
) {
currentRotation = value
}
}
}
}

VinylAlbumCover(
painter = painter,
rotationDegrees = rotation.value
)
}

It animates the rotation by 360 degrees infinitely with a duration of 3000 milliseconds, using linear easing. In case isSongPlaying is false, we check if currentRotation is greater than zero. If so, this means that the pause button was clicked and we set another animation with a target value of currentRotation + 50 to stop by slowing it down the rotation using LinearOutSlowInEasing.

HorizontalPager Setup

We do some state managements to track the our pager actions

   val pagerState = rememberPagerState(pageCount = { playList.count() })
val playingSongIndex = remember {
mutableIntStateOf(0)
}
LaunchedEffect(pagerState.currentPage) {
playingSongIndex.intValue = pagerState.currentPage
player.seekTo(pagerState.currentPage, 0)
}

LaunchedEffect(player.currentMediaItemIndex) {
playingSongIndex.intValue = player.currentMediaItemIndex
pagerState.animateScrollToPage(
playingSongIndex.intValue,
animationSpec = tween(500)
)
}
  • pagerState is responsible for managing the pages in a paginated UI, list of songs in our case.
  • playingSongIndex, is created to keep track of the currently playing song's index.
  • A LaunchedEffect is used to respond to changes in the currentPage of the pagerState. When the current page changes, it updates the playingSongIndex and seeks the media player to the corresponding position in the playlist.
  • Another LaunchedEffect observes changes in the currentMediaItemIndex of the media player. It updates the playingSongIndex and smoothly scrolls the pager to the new index with an animation.

We use both pagerState and playingSongIndex in the following part.

 AnimatedContent(targetState = playingSongIndex.intValue, transitionSpec = {
(scaleIn() + fadeIn()) with (scaleOut() + fadeOut())
}, label = "") {
Text(
text = playList[it].name, fontSize = 24.sp,
color = Color.Black,
style = TextStyle(fontWeight = FontWeight.ExtraBold)
)
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedContent(targetState = playingSongIndex.intValue, transitionSpec = {
(scaleIn() + fadeIn()) with (scaleOut() + fadeOut())
}, label = "") {
Text(
text = playList[it].artist, fontSize = 12.sp, color = Color.Black,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}

Spacer(modifier = Modifier.height(16.dp))



HorizontalPager(
modifier = Modifier.fillMaxWidth(),
state = pagerState,
pageSize = PageSize.Fixed((configuration.screenWidthDp / (1.7)).dp),
contentPadding = PaddingValues(horizontal = 85.dp)
) { page ->

val painter = painterResource(id = playList[page].cover)

if (page == pagerState.currentPage) {
VinylAlbumCoverAnimation(isSongPlaying = isPlaying.value, painter = painter)
} else {
VinylAlbumCoverAnimation(isSongPlaying = false, painter = painter)
}
}

Slider

We create a Slider to track and visualize the song playing actions.

 @Composable
fun TrackSlider(
value: Float,
onValueChange: (newValue: Float) -> Unit,
onValueChangeFinished: () -> Unit,
songDuration: Float
) {
Slider(
value = value,
onValueChange = {
onValueChange(it)
},
onValueChangeFinished = {

onValueChangeFinished()

},
valueRange = 0f..songDuration,
colors = SliderDefaults.colors(
thumbColor = Color.Black,
activeTrackColor = Color.DarkGray,
inactiveTrackColor = Color.Gray,
)
)
}

The TrackSlider composable takes four parameters:

  • value: The current position of the slider
  • onValueChange: A lambda function that is called when the slider value changes. For example, when the user scrolling the slider. That’s why, we update our slider current value with the new one.
  • onValueChangeFinished: A lambda function that is called when the user finishes changing the slider value. There are two cases to call this function. One is a time when the user leave the thumb of slider and the other is clicking on any point on the slider.
  • songDuration: The total duration of the song or media being controlled by the slider.
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
) {

TrackSlider(
value = sliderPosition.longValue.toFloat(),
onValueChange = {
sliderPosition.longValue = it.toLong()
},
onValueChangeFinished = {
currentPosition.longValue = sliderPosition.longValue
player.seekTo(sliderPosition.longValue)
},
songDuration = totalDuration.longValue.toFloat()
)
Row(
modifier = Modifier.fillMaxWidth(),
) {

Text(
text = (currentPosition.longValue).convertToText(),
modifier = Modifier
.weight(1f)
.padding(8.dp),
color = Color.Black,
style = TextStyle(fontWeight = FontWeight.Bold)
)

val remainTime = totalDuration.longValue - currentPosition.longValue
Text(
text = if (remainTime >= 0) remainTime.convertToText() else "",
modifier = Modifier
.padding(8.dp),
color = Color.Black,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}

For usefulness, we can have an extension function that provides us to convert our time value from Long type to String type so that we can use it in a Text composable.

private fun Long.convertToText(): String {
val sec = this / 1000
val minutes = sec / 60
val seconds = sec % 60

val minutesString = if (minutes < 10) {
"0$minutes"
} else {
minutes.toString()
}
val secondsString = if (seconds < 10) {
"0$seconds"
} else {
seconds.toString()
}
return "$minutesString:$secondsString"
}

Control Buttons

We set the buttons to control the music actions such as playing, pausing, skiping next and skiping previous song.

@Composable
fun ControlButton(icon: Int, size: Dp, onClick: () -> Unit) {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.clickable {
onClick()
}, contentAlignment = Alignment.Center
) {
Icon(
modifier = Modifier.size(size / 1.5f),
painter = painterResource(id = icon),
tint = Color.Black,
contentDescription = null
)
}
}

And we use it in three places in a Row Composable as follows

 Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
ControlButton(icon = R.drawable.ic_previous, size = 40.dp, onClick = {
player.seekToPreviousMediaItem()
})
Spacer(modifier = Modifier.width(20.dp))
ControlButton(
icon = if (isPlaying.value) R.drawable.ic_pause else R.drawable.ic_play,
size = 100.dp,
onClick = {
if (isPlaying.value) {
player.pause()
} else {
player.play()
}
isPlaying.value = player.isPlaying
})
Spacer(modifier = Modifier.width(20.dp))
ControlButton(icon = R.drawable.ic_next, size = 40.dp, onClick = {
player.seekToNextMediaItem()
})
}
  • player.seekToPreviousMediaItem() to skip the next song in the play list if it exist, if not it do nothing. And vice versa for player.seekToPreviousMediaItem() . Apart from these, we update isPlaying state here.

Conclusion

In this article, we’ve created a nice looking music player app by using Media3 ExoPlayer with Jetpack Compose. I hope the article helps you guys. Stay tuned and give it a clap if you want such more!

--

--