Recently I finished the content related to the development of music player in the project, and then I spent two days to summarize it.

On the other hand, the music player also uses the four components of Android, which is also worth learning to develop a function for those who just contact Android development. Some of the content may not go into too much detail.

Requirements: Music player features

  1. Music background playback (Service), UI display progress, song information
  2. Music play notification and lock screen notification, operable (play, pause, next song)
  3. Handling of audio focus (status updates when other music players play)
  4. Processing of earphone wire control mode

UI control music playback, update progress

As for the development of music player, MediaSession framework is officially provided above 5.0 to facilitate the development of music-related functions.

The general process is as follows:

It is divided into UI side and Service side. The UI controls playing and pausing operations and transmits information to the Service through MediaController.

Service processes relevant instructions and sends the playback status (song information, playback progress) back to the UI end through MediaSession, which is updated and displayed by the UI end.

As shown above: (Please go to github.com/yunshuipiao…

The top half of the UI is the play state, the middle part is the song list, and the bottom half is the controller. Where loading songs simulates retrieving playlists from different channels.

The UI part is implemented using ViewModel + LiveData as follows:

/** ** ** /
mf_to_previous.setOnClickListener {
    viewModel.skipToPrevious()
}
/**
 * 下一首
 */
mf_to_next.setOnClickListener {
    viewModel.skipToNext()
}
/** * Pause playback */
mf_to_play.setOnClickListener {
    viewModel.playOrPause()
}
/** * Load music */
mf_to_load.setOnClickListener {
    viewModel.getNetworkPlayList()
}
Copy the code

Here is a look at loading songs, play pause is how to control, the main logic in the ViewModel side implementation.

The associated objects of the ViewModel:

class MainViewModel : ViewModel() {

    private lateinit var mContext: Context
    /** * The play controller tells the Service to play, pause, and play the next song */
    private lateinit var mMediaControllerCompat: MediaControllerCompat
    /** * media browser, connect to Service, get Service related information */
    private lateinit var mMediaBrowserCompat: MediaBrowserCompat
    /** * Playback status data (whether it is playing, playback progress) */
    public var mPlayStateLiveData = MutableLiveData<PlaybackStateCompat>()
    /** * Play the data of the song (song, artist, etc.) */
    public var mMetaDataLiveData = MutableLiveData<MediaMetadataCompat>()
    /** * Playlist data */
    public var mMusicsLiveData = MutableLiveData<MutableList<MediaDescriptionCompat>>()
    /** * Plays the controller's callback * (for example, when the UI issues the next command, the Service switches the song playing and sends the status information back to the UI to update the UI) */
    private var mMediaControllerCompatCallback = object : MediaControllerCompat.Callback() {
        override fun onQueueChanged(queue: MutableList<MediaSessionCompat.QueueItem>? {
            super.onQueueChanged(queue)
            // Queue changes on the server
            MusicHelper.log("onQueueChanged: $queue") mMusicsLiveData.postValue(queue? .map { it.description }as MutableList<MediaDescriptionCompat>)

        }

        override fun onRepeatModeChanged(repeatMode: Int) {
            super.onRepeatModeChanged(repeatMode)
        }

        override fun onPlaybackStateChanged(state: PlaybackStateCompat?). {
            super.onPlaybackStateChanged(state)
            mPlayStateLiveData.postValue(state)
            MusicHelper.log("music onPlaybackStateChanged, $state")}override fun onMetadataChanged(metadata: MediaMetadataCompat?). {
            super.onMetadataChanged(metadata)
            MusicHelper.log("onMetadataChanged, $metadata")
            mMetaDataLiveData.postValue(metadata)
        }

        override fun onSessionReady(a) {
            super.onSessionReady()
        }

        override fun onSessionDestroyed(a) {
            super.onSessionDestroyed()
        }

        override fun onAudioInfoChanged(info: MediaControllerCompat.PlaybackInfo?). {
            super.onAudioInfoChanged(info)
        }
    }

    /** * The media browser connects to the Service callback */
    private var mMediaBrowserCompatConnectionCallback: MediaBrowserCompat.ConnectionCallback = object :
        MediaBrowserCompat.ConnectionCallback() {
        override fun onConnected(a) {
            super.onConnected()
            // The connection succeeded
            MusicHelper.log("onConnected")
            mMediaControllerCompat = MediaControllerCompat(mContext, mMediaBrowserCompat.sessionToken)
            mMediaControllerCompat.registerCallback(mMediaControllerCompatCallback)
            mMediaBrowserCompat.subscribe(mMediaBrowserCompat.root, mMediaBrowserCompatSubscriptionCallback)
        }

        override fun onConnectionSuspended(a) {
            super.onConnectionSuspended()
        }

        override fun onConnectionFailed(a) {
            super.onConnectionFailed()
        }
    }

    /** * Media browser subscribes to Service data callback */
      private var mMediaBrowserCompatSubscriptionCallback = object : MediaBrowserCompat.SubscriptionCallback() {
        override fun onChildrenLoaded(
            parentId: String,
            children: MutableList<MediaBrowserCompat.MediaItem>) {
            super.onChildrenLoaded(parentId, children)
            // The server setChildLoad callback method
            MusicHelper.log("onChildrenLoaded, $children")}}Copy the code

See the notes for more information and the process will be introduced step by step.

Initialize the

fun init(context: Context) {
    mContext = context
    mMediaBrowserCompat = MediaBrowserCompat(context, ComponentName(context, MusicService::class.java),
        mMediaBrowserCompatConnectionCallback.null)
    mMediaBrowserCompat.connect()
}
Copy the code

Initialize MedaBrowserCompat and issue the connect instruction to the Service. After the connection is successful, the Service initializes.

The contents of Service are as follows:

class MusicService : MediaBrowserServiceCompat() {

    private var mRepeatMode: Int = PlaybackStateCompat.REPEAT_MODE_NONE
    /** * The playback status is returned to the UI through MediaSession. * /
    private var mState = PlaybackStateCompat.Builder().build()
    /** * The UI may be destroyed, and the Service needs to save the playlist and handle the loop mode */
    private var mPlayList = arrayListOf<MediaSessionCompat.QueueItem>()
    /** * Information about the currently playing music */
    private var mMusicIndex = - 1
    private var mCurrentMedia: MediaSessionCompat.QueueItem? = null
    /** * Plays the session, and sends the playback status information back to the UI. * /
    private lateinit var mSession: MediaSessionCompat
    /** * real music player */
    private var mMediaPlayer: MediaPlayer = MediaPlayer()
    
    /** * The event callback of the playback controller, where the UI side receives the commands issued by the playback controller and gives them to the real music player for processing. * /
    private var mSessionCallback = object : MediaSessionCompat.Callback() {
    ....
    }
Copy the code

You have seen the UI side and the Service side of the music player.

The initialization process continues, and after the connection is successful, the Service initializes.

    override fun onCreate(a) {
        super.onCreate()
        mSession = MediaSessionCompat(applicationContext, "MusicService")
        mSession.setCallback(mSessionCallback)
        mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS)
        sessionToken = mSession.sessionToken
        mMediaPlayer.setOnCompletionListener(mCompletionListener)
        mMediaPlayer.setOnPreparedListener(mPreparedListener)
        mMediaPlayer.setOnErrorListener { mp, what, extra -> true}}Copy the code

This is the job of the UI-side MediaBrowser. The UI receives a successful connection callback.

After a successful connection MediaController is initialized and the listener callback is set. MediaBrowser and subscribe to the Service playlist.

mMediaBrowserCompat.subscribe(mMediaBrowserCompat.root,mMediaBrowserCompatSubscriptionCallback)
Copy the code

When the Service is successfully initialized, the Service implements two methods:

override fun onLoadChildren(
    parentId: String,
    result: Result<MutableList<MediaBrowserCompat.MediaItem>>
) {
    MusicHelper.log("onLoadChildren, $parentId")
    result.detach()
    val list = mPlayList.map { MediaBrowserCompat.MediaItem(it.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) }
    result.sendResult(list as MutableList<MediaBrowserCompat.MediaItem>?)
}

override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle?).: BrowserRoot? {
    return BrowserRoot("MusicService".null)}Copy the code

The onGetRoot method provides root. After the subscription, onLoadChildren will send the current playlist, and the UI side will receive the playlist data of the current Service in the media browser.

Because the playlist is empty, the UI side receives the playlist as well.

MediaSession supports multiple UI access. For example, UI end A sets A playlist, and UI end B connects to it, and the current playlist can be obtained for operation.

Summary: UI side and Service side initialization process

  1. The UI side issues connection instructions to the Service through MediaBroswer.
  2. Service creation initializes the Service by setting the token.
  3. The UI receives a callback indicating that the connection is successful, initializes the MediaController, and MediaBroswer subscribes to the playlist of the Service. The Service sends the current playback information back to the UI via onLoadChildren.
  4. The UI receives the playlist information and updates the UI to display the playlist.

Setting up a playlist

During output initialization, the playlist is empty. Here’s how the UI side gets the playlist and passes it to the Service to play.

The UI side simulates getting a playlist from the network through the following functions.

fun getNetworkPlayList(a) {
   val playList =  MusicLibrary.getMusicList()
    playList.forEach {
        mMediaControllerCompat.addQueueItem(it.description)
    }
}
Copy the code

Add to Service via playback controller.

  • MediaMetadataCompat: The data type of the UI playlist is MediaMetadataCompat, which contains all the information about the content of the song (song name, artist, player URI, icon, etc.)
  • MediaDescriptionCompat: data transferred from the UI to the Service. It is part of the MediaMetadataCompat and is used to display simple information.

The Service receives a playlist callback:

override fun onAddQueueItem(description: MediaDescriptionCompat) {
    super.onAddQueueItem(description)
    // The client adds the song
    if (mPlayList.find { it.description.mediaId == description.mediaId } == null) {
        mPlayList.add(
            MediaSessionCompat.QueueItem(description, description.hashCode().toLong())
        )
    }
    mMusicIndex = if (mMusicIndex == - 1) 0 else mMusicIndex
    mSession.setQueue(mPlayList)
}
Copy the code

Delete the playlist according to mediaId and play the subscript of the song.

  • QueueItem: The contents of the playlist, containing MediaDescriptionCompat.

Set the playlist with session.setQueue (), and the UI side gets a callback to update the playlist.

override fun onQueueChanged(queue: MutableList<MediaSessionCompat.QueueItem>? {
    super.onQueueChanged(queue)
    // Queue changes on the server
    MusicHelper.log("onQueueChanged: $queue") mMusicsLiveData.postValue(queue? .map { it.description }as 	MutableList<MediaDescriptionCompat>)
}

Copy the code

After that, LiveData notifies the UI side of the data and updates the list.

viewModel.mMusicsLiveData.observe(this, Observer {
    mMusicAdapter.setList(it)
})

public fun setList(datas: List<MediaDescriptionCompat>) {
            mList.clear()
            mList.addAll(datas)
            notifyDataSetChanged()
}
Copy the code

Here’s why you don’t update the UI after you get the playlist: Getting the playlist and transferring it to the Service may fail, making the song unplayable.

This also conforms to reactive actions: the UI issues an Action -> handles the Action -> the UI receives the state change caused by the Action and updates the UI.

The UI side should not actively update after an operation. The same is true for the later pauses.

Play to suspend

With the premise of setting the playlist, the following is the related process of playing pause.

The UI sends out the command to play the song through mediaController -> The Service receives the command and switches the song playing -> Sends the playing status information back to the UI end through MediaSession -> THE UI end updates it.

fun playOrPause(a) {
    if(mPlayStateLiveData.value? .state == PlaybackStateCompat.STATE_PLAYING) { mMediaControllerCompat.transportControls.pause() }else {
        mMediaControllerCompat.transportControls.play()
    }
}
Copy the code

UI side: if the current playing state is playing, it sends the command to pause the playing; Otherwise, the command to play is sent.

override fun onPlay(a) {
    super.onPlay()
    if (mCurrentMedia == null) {
        onPrepare()
    }
    if (mCurrentMedia == null) {
        return
    }
    mMediaPlayer.start()
    setNewState(PlaybackStateCompat.STATE_PLAYING)
}
Copy the code

Service: After receiving the play command, the current song is empty, and the song is processed before playing to prepare resources. Returns if the current song is still empty (such as when you hit Play without a playlist). Otherwise, play.

override fun onPrepare(a) {
    super.onPrepare()
    if (mPlayList.isEmpty()) {
        MusicHelper.log("not playlist")
        return
    }
    if (mMusicIndex < 0 || mMusicIndex >= mPlayList.size) {
        MusicHelper.log("media index error")
        return
    }
    mCurrentMedia = mPlayList[mMusicIndex]
    valuri = mCurrentMedia? .description?.mediaUri MusicHelper.log("uri, $uri")
    if (uri == null) {
        return
    }
    // Load resources to reset
    mMediaPlayer.reset()
    try {
        if (uri.toString().startsWith("http")) {
            mMediaPlayer.setDataSource(applicationContext, uri)
        } else {
            / / assets resources
            val assetFileDescriptor = applicationContext.assets.openFd(uri.toString())
            mMediaPlayer.setDataSource(
                assetFileDescriptor.fileDescriptor,
                assetFileDescriptor.startOffset,
                assetFileDescriptor.length
            )
        }
        mMediaPlayer.prepare()
    } catch (e: Exception) {
        e.printStackTrace()
    }
}
Copy the code

Here you get the current song to play and use MediaPlayer to load it. After preparation:

private var mPreparedListener: MediaPlayer.OnPreparedListener =
    MediaPlayer.OnPreparedListener {
        valmediaId = mCurrentMedia? .description?.mediaId ?:""
        val metadata = MusicLibrary.getMeteDataFromId(mediaId)
        mSession.setMetadata(metadata.putDuration(mMediaPlayer.duration.toLong()))
        mSessionCallback.onPlay()
    }
Copy the code

After obtaining the current playing song information, MediaSession sends it to the client through setMetaData() to update the UI.

It will be played again when it is ready. Going back to the code above, MediaSession sends the playback state via setNewState() to the client for a UI update.

private fun setNewState(state: Int) {
    val stateBuilder = PlaybackStateCompat.Builder()
    stateBuilder.setActions(getAvailableActions(state))
    stateBuilder.setState(
        state,
        mMediaPlayer.currentPosition.toLong(),
        1.0f,
        SystemClock.elapsedRealtime()
    )
    mState = stateBuilder.build()
    mSession.setPlaybackState(mState)
}
    
Copy the code

The playback status here includes four parameters: whether it is playing, the current progress, the playback speed, and the latest update time (after using the UI to play the progress update).

The UI receives the song information from MediaMession and updates the UI.

override fun onPlaybackStateChanged(state: PlaybackStateCompat?). {
    super.onPlaybackStateChanged(state)
    mPlayStateLiveData.postValue(state)
    MusicHelper.log("music onPlaybackStateChanged, $state")}override fun onMetadataChanged(metadata: MediaMetadataCompat?). {
    super.onMetadataChanged(metadata)
    MusicHelper.log("onMetadataChanged, $metadata")
    mMetaDataLiveData.postValue(metadata)
}


        viewModel.mPlayStateLiveData.observe(this, Observer {
            if (it.state == PlaybackStateCompat.STATE_PLAYING) {
                mf_to_play.text = "Pause"
                mPlayState = it
                mf_tv_seek.progress = it.position.toInt()
                handler.sendEmptyMessageDelayed(1.250)}else {
                mf_to_play.text = "Play"
                handler.removeMessages(1)

            }
        })
        viewModel.mMetaDataLiveData.observe(this, Observer {
            val title = it.getString(MediaMetadataCompat.METADATA_KEY_TITLE)
            val singer = it.getString(MediaMetadataCompat.METADATA_KEY_ARTIST)
            val duration = it.getLong(MediaMetadataCompat.METADATA_KEY_DURATION)
            val durationShow = "${duration / 60000}: ${duration / 1000 % 60}"
            mf_tv_title.text = Title:"$title"
            mf_tv_singer.text = "Singer:$singer"
            mf_tv_progress.text = "The length:$durationShow"
            mMusicAdapter.notifyPlayingMusic(it.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID))
            mf_tv_seek.max = duration.toInt()
        })
        viewModel.mMusicsLiveData.observe(this, Observer {
            mMusicAdapter.setList(it)
        })
Copy the code

It can also be seen that if the UI needs to display the progress bar, MediaSession does not always send the progress back to the UI.

inner class SeekHandle: Handler() {
    override fun handleMessage(msg: Message?). {
        super.handleMessage(msg)
        var position = (SystemClock.elapsedRealtime() - mPlayState.lastPositionUpdateTime ) * mPlayState.playbackSpeed + mPlayState.position
        mf_tv_seek.progress = position.toInt()
        sendEmptyMessageDelayed(1.250)}}Copy the code

This is to use handle to perform a timed loop task to calculate the current progress and pay attention to handler processing to prevent memory leaks.

The above is the whole music player initialization, play pause process.

The front desk keeps the music playing

Since the Service is destroyed after it has retreated into the background, the music stops playing. This section describes how to use foreground notification to display playback information and control buttons in the notification bar to prevent Service destruction. The lock screen also supports control playback.

Create and start notifications based on switching between different playback states.

sessionToken? .let {valdescription = mCurrentMedia? .description ?: MediaDescriptionCompat.Builder().build()when(state) {
        PlaybackStateCompat.STATE_PLAYING -> {
            val notification = mNotificationManager.getNotification(description, mState, it)
            ContextCompat.startForegroundService(
                this@MusicService,
                Intent(this@MusicService, MusicService::class.java)
            )
            startForeground(MediaNotificationManager.NOTIFICATION_ID, notification)
        }
        PlaybackStateCompat.STATE_PAUSED -> {
            val notification = mNotificationManager.getNotification(
                description, mState, it
            )
            mNotificationManager.notificationManager
                .notify(MediaNotificationManager.NOTIFICATION_ID, notification)
        }
        PlaybackStateCompat.STATE_STOPPED ->  {
            stopSelf()
        }
    }
}
Copy the code

According to the current state, the foreground service will be started in the play state and the notification will be displayed on the notification bar (including the screen-lock notification).

Pause status updates the notification display and updates the relevant buttons. Refer to the MediaNotificationManager file for the code.

Audio focus processing

When player A is playing music, other players are playing music, and both music players are playing, which involves the processing of audio focus.

When the headphones are pulled out, also pause the music.

Back to the onPlay method, before playing a song, you need to actively get the focus of the audio, which can be played (other players lose the focus of the audio and pause the music).

override fun onPlay(a) {
    super.onPlay()
    if (mCurrentMedia == null) {
        onPrepare()
    }
    if (mCurrentMedia == null) {
        return
    }
    if (mAudioFocusHelper.requestAudioFocus()) {
        mMediaPlayer.start()
        setNewState(PlaybackStateCompat.STATE_PLAYING)
    }
}
Copy the code
fun requestAudioFocus(a): Boolean {
    registerAudioNoisyReceiver()
    val result = mAudioManager.requestAudioFocus(
        this,
        AudioManager.STREAM_MUSIC,
        AudioManager.AUDIOFOCUS_GAIN
    )
    return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
Copy the code

When requesting audio focus, register the radio receiver to receive the broadcast when the headphones are dialed out, pausing the music.

fun registerAudioNoisyReceiver(a) {
    if(! mAudioNoisyReceiverRegistered) { context.registerReceiver(mAudioNoisyReceiver, AUDIO_NOISY_INTENT_FILTER) mAudioNoisyReceiverRegistered =true}}fun unregisterAudioNoisyReceiver(a) {
    if (mAudioNoisyReceiverRegistered) {
        context.unregisterReceiver(mAudioNoisyReceiver)
        mAudioNoisyReceiverRegistered = false}}Copy the code

The interface is passed in when the audio focus is requested and can change the playback state when the audio focus changes.

        override fun onAudioFocusChange(focusChange: Int) {
            when (focusChange) {
                /** * get audio focus */
                AudioManager.AUDIOFOCUS_GAIN -> {
                    if(mPlayOnAudioFocus && ! mMediaPlayer.isPlaying) { mSessionCallback.onPlay() }else if (mMediaPlayer.isPlaying) {
                        setVolume(MEDIA_VOLUME_DEFAULT)
                    }
                    mPlayOnAudioFocus = false
                }
                /** * Temporarily loses audio focus, but can play music at lower volume, similar to navigation mode */
                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> setVolume(MEDIA_VOLUME_DUCK)
                /** * Temporarily loses audio focus, and will regain focus after a period of time, such as alarm clock */
                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> if (mMediaPlayer.isPlaying) {
                    mPlayOnAudioFocus = true
                    mSessionCallback.onPause()
                }
                /** * out of focus */
                AudioManager.AUDIOFOCUS_LOSS -> {
                    mAudioManager.abandonAudioFocus(this)
                    mPlayOnAudioFocus = false
                    // Pause the playback here
                    mSessionCallback.onPause()
                }
            }
        }
Copy the code

Wire control mode

When the earphones are connected, the buttons on the earphones also control the music playback.

When the button on the headset is pressed, the Service receives a callback.

override fun onMediaButtonEvent(mediaButtonEvent: Intent?).: Boolean {
    return super.onMediaButtonEvent(mediaButtonEvent)
}
Copy the code

This method has a default implementation, including a notification bar button, and a headset button. The default implementation is: volume increase and decrease, click pause, single player, double click the next song. A return value of true indicates that the button event was processed. Therefore, this method can be rewritten to meet the requirements of wire control.

override fun onMediaButtonEvent(mediaButtonEvent: Intent?).: Boolean {
    valaction = mediaButtonEvent? .actionvalkeyevent = mediaButtonEvent? .getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)valkeyCode= keyevent? .keyCode MusicHelper.log("action: $action, keyEvent: $keyevent")

    return if(keyevent? .keyCode == KeyEvent.KEYCODE_HEADSETHOOK && keyevent.action == KeyEvent.ACTION_UP) {// Headset standalone operation
        mHeadSetClickCount += 1
        if (mHeadSetClickCount == 1) {
            handler.sendEmptyMessageDelayed(1.800)}true
    } else {
        super.onMediaButtonEvent(mediaButtonEvent)
    }

}
Copy the code

If it is the operation of the earphone button, count how many times the button is pressed within 800 milliseconds to realize the inline mode.

inner class HeadSetHandler: Handler() {
    override fun handleMessage(msg: Message) {
        super.handleMessage(msg)
        // What operation is performed according to the number of times the headset is pressed
        when(mHeadSetClickCount) {
            1- > {if (mMediaPlayer.isPlaying) {
                    mSessionCallback.onPause()
                } else {
                    mSessionCallback.onPlay()
                }
            }
            2 -> {
                mSessionCallback.onSkipToNext()
            }
            3 -> {
                mSessionCallback.onSkipToPrevious()
            }
            4 -> {
                mSessionCallback.onSkipToPrevious()
                mSessionCallback.onSkipToPrevious()
            }
        }
    }
}
Copy the code

conclusion

So far, we have implemented the music player features described at the beginning of this article, using MediaSession as the basic (underlying) Binder for UI-side and service-side communication.

It is important to understand the role and use of MediaSession so as to understand the communication mechanism of the player more easily.

Source: making

)