Create a Music Player on Android: User Controls
We are building a simple music player app for Android in this series. By the end of this tutorial, our basic music player app will be complete. We learned how to display a list of songs from the user's device in the first tutorial. The second tutorial explained how we can play individual songs from the list. The only thing missing now is the code that allows users to control the playback of different songs.
In this tutorial, we will learn how to let users control the playback of different songs. This includes the ability to play, pause, or seek a particular song. Users will also be able to play the next or previous tracks, as well as turn shuffling on to play the songs in random order.
We will also display a notification during the playback so that the user can jump back to the music player after using other apps.
At the end of this tutorial, you will have a music player that looks like the image below:



Creating a Controller
Since we want to add playback controls for our media player, we need to implement the MediaPlayerControl
interface first. Update your MainActivity
class declaration so that it looks like the line below:
1 |
class MainActivity : AppCompatActivity(), MediaPlayerControl |
You will now see an error message about unimplemented methods if you hover over the class name. You can get rid of the error by telling Android Studio to add the unimplemented methods.
Add a new Kotlin class to your project and name it MusicController
. This will create a new file called MusicController.kt. The MusicController
class extends the MediaController
class and overrides one of its methods called hide()
.
Your MusicController.kt file should now have the following code:
1 |
package com.tutsplus.musicplayer |
2 |
|
3 |
import android.content.Context |
4 |
import android.widget.MediaController |
5 |
|
6 |
|
7 |
class MusicController(c: Context?) : MediaController(c) { |
8 |
override fun hide() {} |
9 |
}
|
The MediaController
class presents a standard widget with play/pause, rewind, fast-forward, and skip (previous/next) buttons in it. The widget also contains a seek bar, which updates as the song plays and contains text indicating the duration of the song and the player's current position.
With this extended class, we can customize the behavior of the controller according to our requirements. In this case, we simply want to prevent the controls from hiding automatically after three seconds. We do this by overriding the hide()
method.
Now, update the MainActivity
class to add the following variables near the top:
1 |
private lateinit var controller: MusicController |
2 |
|
3 |
private var paused: Boolean = false |
4 |
private var playbackPaused: Boolean = false |
Create a helper method called setController()
and add the following code to it:
1 |
private fun setController() { |
2 |
controller = MusicController(this) |
3 |
|
4 |
controller.setPrevNextListeners({ playNext() } |
5 |
) { playPrev() } |
6 |
|
7 |
controller.setMediaPlayer(this) |
8 |
controller.setAnchorView(findViewById(R.id.song_list)) |
9 |
controller.isEnabled = true |
10 |
}
|
We begin by instantiating the MusicController
class and then assign the playNext()
and playPrev()
methods to its next and previous button click listeners respectively. The setAnchorView()
method tells the app to anchor the controller UI to our specified view.
Let's define the playNext()
and playPrev()
methods now:
1 |
private fun playNext() { |
2 |
musicService!!.playNext() |
3 |
if(playbackPaused){ |
4 |
setController() |
5 |
playbackPaused=false |
6 |
}
|
7 |
controller.show(0) |
8 |
}
|
9 |
|
10 |
private fun playPrev() { |
11 |
musicService!!.playPrev() |
12 |
if(playbackPaused){ |
13 |
setController() |
14 |
playbackPaused=false |
15 |
}
|
16 |
controller.show(0) |
17 |
}
|
The call to the show()
method of the controller object with a value of 0 means that the media controller will be displayed on the screen until it is manually hidden by the user.
Now, update the displaySongs()
method in MainActivity
to add a call to setController()
at the end.
1 |
setController() |
Implement Playback Control
Remember that the media playback is happening in the MusicService
class, but the user interface comes from the MainActivity
class. In the previous tutorial, we bound the MainActivity
instance to the MusicService
instance, so that we could control playback from the user interface.
The methods in our MainActivity
class that we added to implement the MediaPlayerControl
interface will be called when the user attempts to control playback. We will need the MusicService
class to act on any events related to those controls. Open your MusicService
class now to add a few more methods to it:
1 |
fun getPosition(): Int { |
2 |
return mediaPlayer.currentPosition |
3 |
}
|
4 |
|
5 |
fun getDuration(): Int { |
6 |
return mediaPlayer.duration |
7 |
}
|
8 |
|
9 |
fun isPlaying(): Boolean { |
10 |
return mediaPlayer.isPlaying |
11 |
}
|
12 |
|
13 |
fun pausePlayer() { |
14 |
mediaPlayer.pause() |
15 |
}
|
16 |
|
17 |
fun seek(position: Int) { |
18 |
mediaPlayer.seekTo(position) |
19 |
}
|
20 |
|
21 |
fun go() { |
22 |
mediaPlayer.start() |
23 |
}
|
In the previous section, we added the playNext()
and playPrev()
methods to our MainActivity
class. Inside those methods, we call the playNext()
and playPrev()
methods of the MusicService
class. We will now define those methods inside the MusicService
class.
1 |
fun playPrev() { |
2 |
songPosition-- |
3 |
if (songPosition < 0) songPosition = songs.size - 1 |
4 |
playSong() |
5 |
}
|
6 |
|
7 |
fun playNext() { |
8 |
songPosition++ |
9 |
if (songPosition >= songs.size) songPosition = 0 |
10 |
playSong() |
11 |
}
|
In both methods, we check if the songPosition
falls outside the list of songs, and then we reset it accordingly. After that, we make a call to playSong()
in order to play the song at the current position.
We will now update the implementation of some of the methods of the MediaPlayerControl
interface. We are simply returning true for these methods to all users to pause, seek forward, or seek backward while playing a song. Add the following code to your MainActivity
class.
1 |
override fun canPause(): Boolean { |
2 |
return true |
3 |
}
|
4 |
|
5 |
override fun canSeekBackward(): Boolean { |
6 |
return true |
7 |
}
|
8 |
|
9 |
override fun canSeekForward(): Boolean { |
10 |
return true |
11 |
}
|
12 |
|
13 |
override fun getAudioSessionId(): Int { |
14 |
return 1 |
15 |
}
|
16 |
|
17 |
override fun getBufferPercentage(): Int { |
18 |
val duration = musicService!!.getDuration() |
19 |
if (duration > 0) { |
20 |
return (musicService!!.getPosition() * 100)/(duration) |
21 |
}
|
22 |
return 0 |
23 |
}
|
24 |
|
25 |
override fun getCurrentPosition(): Int { |
26 |
if (musicService != null && musicBound && musicService!!.isPlaying()) |
27 |
return musicService!!.getPosition() |
28 |
else return 0 |
29 |
}
|
In the above snippet, you might have noticed that we called the getPosition()
method from the musicService
class to calculate the buffer percentage and to get the current position within MainActivity
.
Let's update the implementation of some more methods so that they make a call to the respective methods inside the MusicService
class.
1 |
override fun start() { |
2 |
musicService!!.go() |
3 |
}
|
4 |
|
5 |
override fun pause() { |
6 |
playbackPaused = true |
7 |
musicService!!.pausePlayer() |
8 |
}
|
9 |
|
10 |
override fun seekTo(p0: Int) { |
11 |
musicService!!.seek(p0) |
12 |
}
|
13 |
|
14 |
override fun isPlaying(): Boolean { |
15 |
if(musicService!=null && musicBound) |
16 |
return musicService!!.isPlaying() |
17 |
return false |
18 |
}
|
19 |
|
20 |
override fun getDuration(): Int { |
21 |
if(musicService!=null && musicBound && musicService!!.isPlaying()) |
22 |
return musicService!!.getDuration() |
23 |
else return 0 |
24 |
}
|
Handling Navigation Back to the App
When a user starts playing a song in any music app, they expect the song to keep playing even if they navigate away from the app to do something else. We can implement this feature by showing users a notification that displays the title of the current song being played. Clicking on the notification will take users to the app.
Begin by adding the following variables to the MusicService
class.
1 |
private var songTitle: String? = "" |
2 |
private val notifyId = 1 |
In our second tutorial, where we implemented playback, the onPrepared()
method of the MusicService
class was simply starting the media player. We will now update the method to create and display a notification. Here is the complete code of the method.
1 |
override fun onPrepared(mediaPlayer: MediaPlayer?) { |
2 |
mediaPlayer?.start() |
3 |
|
4 |
val channelId = "my_channel_id" |
5 |
|
6 |
fun createNotificationChannel(channelId: String, channelName: String): String { |
7 |
lateinit var notificationChannel: NotificationChannel |
8 |
|
9 |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
10 |
notificationChannel = NotificationChannel( |
11 |
channelId, |
12 |
channelName, NotificationManager.IMPORTANCE_DEFAULT |
13 |
)
|
14 |
|
15 |
notificationChannel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC |
16 |
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager |
17 |
manager.createNotificationChannel(notificationChannel) |
18 |
}
|
19 |
return channelId |
20 |
}
|
21 |
|
22 |
val pendingIntent: PendingIntent = |
23 |
Intent(this, MainActivity::class.java).let { intent -> |
24 |
PendingIntent.getActivity(this, 0, intent, |
25 |
PendingIntent.FLAG_IMMUTABLE) |
26 |
}
|
27 |
|
28 |
|
29 |
val notification: Notification = NotificationCompat.Builder(this, channelId) |
30 |
.setSmallIcon(R.drawable.ic_launcher_foreground) |
31 |
.setOngoing(true) |
32 |
.setContentTitle("Playing") |
33 |
.setContentText(songTitle) |
34 |
.setContentIntent(pendingIntent) |
35 |
.setTicker(songTitle) |
36 |
.build() |
37 |
|
38 |
createNotificationChannel(channelId, "My Music Player") |
39 |
|
40 |
startForeground(notifyId, notification) |
41 |
}
|
After starting the media player, we create a string variable to store the channel ID. On the next line, we define a function called createNotificationChannel()
to create a NotificationChannel
with our specified ID and name.
Notification channels provide a way to group notifications. They were introduced in Android 8.0 or API level 26. We have to create a notification channel to display notifications in any Android version above 8.0.
Next, we create a PendingIntent
that will launch the main activity of our app once clicked. Finally, we create a new notification using the notification builder. I have passed true
to the setOngoing()
method to prevent users from accidentally swiping away the notification.
Make sure that you update the playSong()
method to include the following line to set the value of songTitle
to the currently playing song.
1 |
songTitle = playSong.name |
Finally, update the onDestroy()
method of the MusicService
class to remove the notification once the app is destroyed.
1 |
override fun onDestroy() { |
2 |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { |
3 |
stopForeground(STOP_FOREGROUND_REMOVE) |
4 |
} else { |
5 |
stopForeground(true) |
6 |
}
|
7 |
}
|
Shuffle Playback
So far, the Shuffle button in our app isn't doing anything. We will now write some code for this button so that the song to be played next is selected at random.
Begin by adding the following variables to the MusicService
class.
1 |
private var shuffle = false |
2 |
private var random: Random? = null |
Now, add the setShuffle()
method to the MusicService
class. This method simply toggles the value of the shuffle
variable.
1 |
fun setShuffle() { |
2 |
shuffle = !shuffle |
3 |
}
|
Update the playNext()
method so that it checks for the shuffle
value and selects a song at random if it is set to true
.
1 |
fun playNext() { |
2 |
if (shuffle) { |
3 |
var newSong = songPosition |
4 |
while (newSong == songPosition) { |
5 |
newSong = random!!.nextInt(songs.size) |
6 |
}
|
7 |
songPosition = newSong |
8 |
} else { |
9 |
songPosition++ |
10 |
if (songPosition >= songs.size) songPosition = 0 |
11 |
}
|
12 |
playSong() |
13 |
}
|
Finally, add the shuffleSongs()
and stopSong()
methods to the MainActivity
class.
1 |
fun shuffleSongs(view: View) { |
2 |
musicService?.setShuffle() |
3 |
}
|
4 |
|
5 |
fun stopSong(view: View) { |
6 |
stopService(playIntent) |
7 |
musicService = null |
8 |
exitProcess(0) |
9 |
}
|
Updating Lifecycle Methods
We will now make some changes to the onPause()
, onResume()
, and onStop()
methods to do some initializations and cleanups. Add the following code to the MainActivity
class.
1 |
override fun onPause() { |
2 |
super.onPause() |
3 |
paused = true |
4 |
}
|
5 |
|
6 |
override fun onResume() { |
7 |
super.onResume() |
8 |
if (paused) { |
9 |
setController() |
10 |
paused = false |
11 |
}
|
12 |
}
|
13 |
|
14 |
override fun onStop() { |
15 |
controller.hide() |
16 |
super.onStop() |
17 |
}
|
Also update the songPicked()
method inside MainActivity
to handle paused playback.
1 |
fun songPicked(view: View) { |
2 |
musicService!!.setSong(view.tag.toString().toInt()) |
3 |
musicService!!.playSong() |
4 |
|
5 |
if(playbackPaused){ |
6 |
setController() |
7 |
playbackPaused=false |
8 |
}
|
9 |
controller.show(0) |
10 |
}
|
With this update, we resume the playback after a new song is picked if it was paused earlier.
The last thing you need to do is update the onError()
and onCompletion()
methods of the MusicService
class to reset the media player and play the next song.
1 |
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { |
2 |
mp.reset() |
3 |
return false |
4 |
}
|
5 |
|
6 |
override fun onCompletion(mp: MediaPlayer) { |
7 |
if (mediaPlayer.currentPosition > 0) { |
8 |
mp.reset() |
9 |
playNext() |
10 |
}
|
11 |
}
|
Final Thoughts
At this point, you should have a fully functioning music player app that you can install and use on your own device. Please keep in mind that the aim of this tutorial was to get you started with the basics.
There are a lot of improvements that you can make to this app to enhance its UI or implement additional features. You will also need to update some of these methods depending on how many user devices and API levels you want to support.