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. They 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 | classMainActivity: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 | packagecom.tutsplus.musicplayer |
2 | |
3 | importandroid.content.Context |
4 | importandroid.widget.MediaController |
5 | |
6 | |
7 | classMusicController(c:Context?):MediaController(c){ |
8 | overridefunhide(){} |
9 | } |
TheMediaController
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 to it near the top:
1 | privatelateinitvarcontroller:MusicController |
2 | |
3 | privatevarpaused:Boolean=false |
4 | privatevarplaybackPaused:Boolean=false |
Create a helper method called setController()
and add the following code to it:
1 | privatefunsetController(){ |
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 | privatefunplayNext(){ |
2 | musicService!!.playNext() |
3 | if(playbackPaused){ |
4 | setController() |
5 | playbackPaused=false |
6 | } |
7 | controller.show(0) |
8 | } |
9 | |
10 | privatefunplayPrev(){ |
11 | musicService!!.playPrev() |
12 | if(playbackPaused){ |
13 | setController() |
14 | playbackPaused=false |
15 | } |
16 | controller.show(0) |
17 | } |
The call to the show()
method of 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 MainAcitvity
to add a call to setController()
at the end.
1 | setController() |
Implement Playback Control
Remember that the media playback is happening in theMusicService
class, but that the user interface comes from the MainActivity
class. In the previous tutorial, we bound theMainActivity
instance to theMusicService
instance, so that we could control playback from the user interface.
The methods in ourMainActivity
class that we added to implement theMediaPlayerControl
interface will be called when the user attempts to control playback. We will need theMusicService
class to act on any events related to those controls. Open yourMusicService
class now to add a few more methods to it:
1 | fungetPosition():Int{ |
2 | returnmediaPlayer.currentPosition |
3 | } |
4 | |
5 | fungetDuration():Int{ |
6 | returnmediaPlayer.duration |
7 | } |
8 | |
9 | funisPlaying():Boolean{ |
10 | returnmediaPlayer.isPlaying |
11 | } |
12 | |
13 | funpausePlayer(){ |
14 | mediaPlayer.pause() |
15 | } |
16 | |
17 | funseek(position:Int){ |
18 | mediaPlayer.seekTo(position) |
19 | } |
20 | |
21 | fungo(){ |
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 | funplayPrev(){ |
2 | songPosition-- |
3 | if(songPosition<0)songPosition=songs.size-1 |
4 | playSong() |
5 | } |
6 | |
7 | funplayNext(){ |
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 song at 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 | overridefuncanPause():Boolean{ |
2 | returntrue |
3 | } |
4 | |
5 | overridefuncanSeekBackward():Boolean{ |
6 | returntrue |
7 | } |
8 | |
9 | overridefuncanSeekForward():Boolean{ |
10 | returntrue |
11 | } |
12 | |
13 | overridefungetAudioSessionId():Int{ |
14 | return1 |
15 | } |
16 | |
17 | overridefungetBufferPercentage():Int{ |
18 | valduration=musicService!!.getDuration() |
19 | if(duration>0){ |
20 | return(musicService!!.getPosition()*100)/(duration) |
21 | } |
22 | return0 |
23 | } |
24 | |
25 | overridefungetCurrentPosition():Int{ |
26 | if(musicService!=null&&musicBound&&musicService!!.isPlaying()) |
27 | returnmusicService!!.getPosition() |
28 | elsereturn0 |
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 | overridefunstart(){ |
2 | musicService!!.go() |
3 | } |
4 | |
5 | overridefunpause(){ |
6 | playbackPaused=true |
7 | musicService!!.pausePlayer() |
8 | } |
9 | |
10 | overridefunseekTo(p0:Int){ |
11 | musicService!!.seek(p0) |
12 | } |
13 | |
14 | overridefunisPlaying():Boolean{ |
15 | if(musicService!=null&&musicBound) |
16 | returnmusicService!!.isPlaying() |
17 | returnfalse |
18 | } |
19 | |
20 | overridefungetDuration():Int{ |
21 | if(musicService!=null&&musicBound&&musicService!!.isPlaying()) |
22 | returnmusicService!!.getDuration() |
23 | elsereturn0 |
24 | } |
Handling Navigation Back Into 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 | privatevarsongTitle:String?="" |
2 | privatevalnotifyId=1 |
In our second tutorial where we implement 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 | overridefunonPrepared(mediaPlayer:MediaPlayer?){ |
2 | mediaPlayer?.start() |
3 | |
4 | valchannelId="my_channel_id" |
5 | |
6 | funcreateNotificationChannel(channelId:String,channelName:String):String{ |
7 | lateinitvarnotificationChannel: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 | valmanager=getSystemService(NOTIFICATION_SERVICE)asNotificationManager |
17 | manager.createNotificationChannel(notificationChannel) |
18 | } |
19 | returnchannelId |
20 | } |
21 | |
22 | valpendingIntent:PendingIntent= |
23 | Intent(this,MainActivity::class.java).let{intent-> |
24 | PendingIntent.getActivity(this,0,intent, |
25 | PendingIntent.FLAG_IMMUTABLE) |
26 | } |
27 | |
28 | |
29 | valnotification: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. In 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 accidently 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 onDestory()
method of the MusicService
class to remove the notification once the app is destroyed.
1 | overridefunonDestroy(){ |
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 | privatevarshuffle=false |
2 | privatevarrandom:Random?=null |
Now add the setShuffle()
method to the MusicService
class. This method simply toggles the value of the shuffle
variable.
1 | funsetShuffle(){ |
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 | funplayNext(){ |
2 | if(shuffle){ |
3 | varnewSong=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()
method to the MainActivity
class.
1 | funshuffleSongs(view:View){ |
2 | musicService?.setShuffle() |
3 | } |
4 | |
5 | funstopSong(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 MainAcitvity
class.
1 | overridefunonPause(){ |
2 | super.onPause() |
3 | paused=true |
4 | } |
5 | |
6 | overridefunonResume(){ |
7 | super.onResume() |
8 | if(paused){ |
9 | setController() |
10 | paused=false |
11 | } |
12 | } |
13 | |
14 | overridefunonStop(){ |
15 | controller.hide() |
16 | super.onStop() |
17 | } |
Also update the songPicked()
method inside MainActivity
to handle paused playback.
1 | funsongPicked(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 | overridefunonError(mp:MediaPlayer,what:Int,extra:Int):Boolean{ |
2 | mp.reset() |
3 | returnfalse |
4 | } |
5 | |
6 | overridefunonCompletion(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.