ゼロから学ぶメディアプレイヤーの実装

はじめに

メディアプレイヤー作っていますか?
実際に作ろうとすると色々な対応が必要になることに気付かされます。

例えば、

  • アプリを落としても再生し続ける
  • 電話が掛かってきたら止める
  • イヤホンが抜けたらスピーカーで鳴らないように止める
  • ループ・シャッフル
  • ヘッドホンのボタンを押した時に曲を操作する

などなど。

今回はこれらに対応できる、一般的な音楽プレイヤーを作っていきます。

MediaSessionとは

実装していく前に、MediaSessionに関して知っておく必要があります。
と言っても、仕組みを完全に理解する必要はありません。まずは再生できるまでを学習し、必要になったらより詳しい解説を都度見れば良いと思います。
なので詳しい解説は公式ドキュメントにお任せしますが、要するに

色々な所から送られてくるプレイヤーの操作・情報をMediaSessionを使って処理しよう ということです。

例えば、ヘッドホンの再生ボタンを押した時、MediaSessionを使わない場合は自前でイベントを処理しプレイヤーを操作する必要がありますが、 MediaSessionを経由することで、開発者はプレイヤーの操作に集中することができます。

※よくある勘違いですが、MediaSessionは命令を受け取るだけです。次曲スキップ・シャッフルなどのMediaPlayerの操作自体は自分で作る必要があります。

操作側をクライアント、MediaSessionを保持している側をサーバーと考えると分かりやすいです。
クライアント接続時、MediaSessionが返却するトークンを利用して操作します。

最初は登場クラスが多く、どのクラスをUIに配置するか、Serviceに配置するか等に迷うと思いますが、
それぞれのクラスの役割は多く無いので、一つずつ理解していくと、とても分かりやすい設計だと感じるはずです。

では、実装していきましょう。1ステップずつに分けているので、順番に理解していけば良いです。

実装

ライブラリの用意

今回の記事では、互換性を考慮して、各種Comatクラスを利用しています。

implementation 'androidx.appcompat:appcompat:1.1.0-beta01'
implementation "androidx.media:media:1.1.0-rc01"

音楽情報の管理

音楽の情報はMediaMetadataCompatで管理します。
MediaMetadataCompat.getDescription()でより扱い易いMediaDescriptionCompatも取得可能です。

今回はソース内に配置したmp3ファイルのメタ情報から、上記クラスを生成します。
予めres/raw配下にmp3ファイルを入れておきましょう。
ネットワーク経由で取得する場合も基本は同じです。取得元が変わるだけになります。

object MusicLibrary {

    fun getAssetsFile(context: Context): AssetFileDescriptor {
        return context.resources.openRawResourceFd(R.raw.sample)
    }

    fun getMetadata(context: Context): MediaMetadataCompat {
        val retriever = MediaMetadataRetriever().apply {
            val afd = getAssetsFile(context)
            setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
        }
        val meta = MediaMetadataCompat.Builder()
                .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, "sample.mp3")
                .putString(MediaMetadataCompat.METADATA_KEY_TITLE,
                        retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE))
                .putString(MediaMetadataCompat.METADATA_KEY_ARTIST,
                        retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST))
                .putBitmap(MediaMetadataCompat.METADATA_KEY_ART,
                        createArt(retriever))
                .build()
        retriever.release()
        return meta
    }    

    private fun createArt(retriever: MediaMetadataRetriever): Bitmap? {
        return try {
            val pic = retriever.embeddedPicture
            BitmapFactory.decodeByteArray(pic, 0, pic.size)
        } catch (e: Exception) {
            Timber.w(e)
            null
        }
    }
}

MediaMetadataRetrieverでmp3ファイルからメタ情報を取得しているだけです。

今回はここで配置したmp3を再生・表示していきます。

音楽を再生できるようにする

再生にはMediaPlayerを利用します。
※HLSなどを再生したい場合はExoplayerを利用しますが、今回はmp3再生なので利用しません。

色々と設定が必要ですが、再生に必要なステップは以下です。

  • MediaBrowserServiceCompatを継承したServiceを作る
  • MediaSessionとMediaPlayerを保持させる(別Serviceでも良いが、分ける必要も特になし)
class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var stateBuilder: PlaybackStateCompat.Builder
    private lateinit var mediaSession: MediaSessionCompat

    private val mediaPlayer = MediaPlayer()

    private val callback = object : MediaSessionCompat.Callback() {

        override fun onPrepare() {
            mediaSession.setMetadata(MusicLibrary.getMetadata(baseContext))
            mediaPlayer.reset()
            val fd = MusicLibrary.getAssetsFile(baseContext)
            mediaPlayer.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length)
            mediaPlayer.prepare()
        }

        override fun onPlay() {
            mediaSession.isActive = true
            mediaPlayer.start()
            startService(Intent(baseContext, MediaPlaybackService::class.java))
        }

        override fun onPause() {
            mediaPlayer.pause()
        }

        override fun onStop() {
            mediaPlayer.stop()
            mediaSession.isActive = false
            stopSelf()
        }
    }

    override fun onCreate() {
        super.onCreate()
        mediaSession = MediaSessionCompat(baseContext, MediaPlaybackService::class.simpleName!!).apply {
            stateBuilder = PlaybackStateCompat.Builder()
            stateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY
                    or PlaybackStateCompat.ACTION_PLAY_PAUSE
            )
            setPlaybackState(stateBuilder.build())
            setCallback(callback)
            setSessionToken(sessionToken)
        }
    }

    override fun onGetRoot(
            clientPackageName: String,
            clientUid: Int,
            rootHints: Bundle?
    ): BrowserRoot? {
        return BrowserRoot("app-media-root", null)
    }

    override fun onLoadChildren(
            parentId: String,
            result: Result<MutableList<MediaBrowserCompat.MediaItem>>
    ) {
        val meta = MusicLibrary.getMetadata(baseContext)
        val list = mutableListOf(
                MediaBrowserCompat.MediaItem(meta.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
        )
        result.sendResult(list)
    }
}

onGetRootは接続してきたクライアントが妥当かチェックします。
接続を拒否する場合はnullを返しましょう。
今回は特に制限を掛けません。固定で返してしまいます。

onLoadChildrenではクライアントに渡す曲リストを返します。
クライアントはサーバーが返す曲リストをUIに表示するだけのイメージ。
また、クライアント側から渡すparentIdを基に、リスト情報を切り替えられます。アルバム一覧など。

MediaSessionCompat.Callbackで実際に操作を受け取り、保持しているMediaPlayerを操作します。
これで、操作元を気にする必要がなくなり、MediaPlayerや状態切替(再生・一時停止など)の操作に集中できます。

ここでまた新しいクラスPlaybackStateCompatが出てきます
再生状態の管理に利用されます。また、許容できる操作も管理しています。

とりあえず再生、一時停止可能にしておきましょう。 stateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY_PAUSE)

最後にServiceなのでManifestに登録しましょう

<!-- Media Player -->
<service
    android:name=".presentation.media.MediaPlaybackService"
    android:exported="false"
    >
    <intent-filter>
        <action android:name="android.media.browse.MediaBrowserService" />
    </intent-filter>
</service>

これで、操作を受け取る用意ができました。あとはこいつに再生しろという命令を送りましょう。

画面から再生させる

まずUIイメージです hahaha

はい、UI作っていきましょう。
画面レイアウトはお任せするので、xmlは省略します。

必要なステップは以下です。

今回は画面を起動したら1曲目を再生待機状態にするという仕様にします。(1曲しかないけど)

class MediaPlayerFragment : Fragment() {

    private lateinit var mediaBrowser: MediaBrowserCompat

    private val subscriptionCallback = object : MediaBrowserCompat.SubscriptionCallback() {

        override fun onChildrenLoaded(
                parentId: String,
                children: MutableList<MediaBrowserCompat.MediaItem>
        ) {
            // 曲リストを受け取ってますが、1曲だけなので、特に利用してません。とにかくprepareを呼び出します。
            MediaControllerCompat.getMediaController(requireActivity())?.transportControls?.prepare()
        }
    }

    private val connectionCallback = object : MediaBrowserCompat.ConnectionCallback() {

        override fun onConnected() {
            // 接続後、受け取ったTokenで操作するようにします。
            MediaControllerCompat.setMediaController(requireActivity(),
                    MediaControllerCompat(context, mediaBrowser.sessionToken))

            // 接続したので、曲リストを購読します。ここでparentIdを渡しています。
            mediaBrowser.subscribe(mediaBrowser.root, subscriptionCallback)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mediaBrowser = MediaBrowserCompat(
                context,
                ComponentName(requireContext(), MediaPlaybackService::class.java),
                connectionCallback,
                null
        )
    }

    // お作法的なコード
    override fun onStart() {
        super.onStart()
        mediaBrowser.connect()
    }

    // お作法的なコード
    override fun onStop() {
        super.onStop()
        MediaControllerCompat.getMediaController(requireActivity())?.unregisterCallback(controllerCallback)
        mediaBrowser.disconnect()
    }
}

これで操作する準備はできました。

MediaControllerCompat.getMediaController(requireActivity()).transportControls.play() とでもすれば再生されるでしょう。

しかし、まだ足りません。

MediaSessionは様々な場所から操作されます。MediaSessionの変化に合わせてUIを操作する必要があります。

曲情報を更新する

ヘッドホンの操作などで曲が変わった場合、UIも更新する必要があります。

手順は以下です。

  • サービス側でMediaSession.setMetadata(MediaMetadataCompat)を使って、曲情報を登録する
  • UI側でMediaControllerCompat.Callback().onMetadataChanged(MediaMetadataCompat) から変更を取得し反映する

今回は曲準備時に、メタ情報を登録しています。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var mediaSession: MediaSessionCompat

    private val callback = object : MediaSessionCompat.Callback() {

        override fun onPrepare() {
            mediaSession.setMetadata(MusicLibrary.getMetadata(baseContext))
        }
        ...
class MediaPlayerFragment : Fragment() {

    private val controllerCallback = object : MediaControllerCompat.Callback() {

        override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
            // 曲情報の変化に合わせてUI更新
            binding.title.text = metadata?.getString(MediaMetadataCompat.METADATA_KEY_TITLE)
            binding.artist.text = metadata?.getString(MediaMetadataCompat.METADATA_KEY_ARTIST)
            metadata?.getBitmap(MediaMetadataCompat.METADATA_KEY_ART)?.also { image ->
                binding.art.setImageBitmap(image)
            }
        }

このコールバックいつ登録するんだ? という所ですが、まだ不足しているので次に行きます。

再生・一時停止をする

再生・一時停止の切り替えは、再生中かどうかによってUIが変わります。(よくある再生・一時停止ボタン)

そしてこちらも、ヘッドホン等で切り替えた場合にUIが変わらないと困るので対応します。

まずはService側でプレイヤーの再生状態が変わったことを通知するようにしましょう。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var stateBuilder: PlaybackStateCompat.Builder

    @PlaybackStateCompat.State
    private var mediaState: Int = PlaybackStateCompat.STATE_NONE

    private val callback = object : MediaSessionCompat.Callback() {

        override fun onPrepare() {
            setNewState(PlaybackStateCompat.STATE_PAUSED)
        }

        override fun onPlay() {
            setNewState(PlaybackStateCompat.STATE_PLAYING)
        }

        override fun onPause() {
            setNewState(PlaybackStateCompat.STATE_PAUSED)
        }

        override fun onStop() {
            setNewState(PlaybackStateCompat.STATE_STOPPED)
        }
    }

    private fun setNewState(@PlaybackStateCompat.State newState: Int) {
        mediaState = newState
        stateBuilder = PlaybackStateCompat.Builder()
        stateBuilder
                .setActions(getAvailableActions())
                .setState(newState, mediaPlayer.currentPosition.toLong(), 1.0f)
        mediaSession.setPlaybackState(stateBuilder.build())
    }

    // 現在の再生状態によって、許容する操作を切り替える
    @PlaybackStateCompat.Actions
    private fun getAvailableActions(): Long {
        var actions = (
                PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
                        or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
                        or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
                        or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
                )
        actions = when (mediaState) {
            PlaybackStateCompat.STATE_STOPPED -> actions or (
                    PlaybackStateCompat.ACTION_PLAY
                            or PlaybackStateCompat.ACTION_PAUSE
                    )
            PlaybackStateCompat.STATE_PLAYING -> actions or (
                    PlaybackStateCompat.ACTION_STOP
                            or PlaybackStateCompat.ACTION_PAUSE
                            or PlaybackStateCompat.ACTION_SEEK_TO
                    )
            PlaybackStateCompat.STATE_PAUSED -> actions or (
                    PlaybackStateCompat.ACTION_PLAY
                            or PlaybackStateCompat.ACTION_STOP
                    )
            else -> actions or (
                    PlaybackStateCompat.ACTION_PLAY
                            or PlaybackStateCompat.ACTION_PLAY_PAUSE
                            or PlaybackStateCompat.ACTION_STOP
                            or PlaybackStateCompat.ACTION_PAUSE
                    )
        }
        return actions
    }
}

mediaSession.setPlaybackState(stateBuilder.build()) によって、最新の再生状態を通知しています。

これで、外部から一時停止されても、stateが切り替わります。

こちらをUI側で取得し、再生ボタンの切り替えを行いましょう。先程保留したコールバックと同じクラスのメソッドです。

class MediaPlayerFragment : Fragment() {

    private val connectionCallback = object : MediaBrowserCompat.ConnectionCallback() {

        override fun onConnected() {
            MediaControllerCompat.setMediaController(requireActivity(), MediaControllerCompat(context, mediaBrowser.sessionToken))
            // 追加
            buildTransportControls()
            mediaBrowser.subscribe(mediaBrowser.root, subscriptionCallback)
        }
    }

    private val controllerCallback = object : MediaControllerCompat.Callback() {

        // 追加
        override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
            when (state?.state) {
                PlaybackStateCompat.STATE_PLAYING -> {
                    binding.play.setImageResource(R.drawable.ic_pause_black)
                }
                else -> {
                    binding.play.setImageResource(R.drawable.ic_play_arrow_black)
                }
            }
        }
    }

    private fun buildTransportControls() {
        val mediaController = MediaControllerCompat.getMediaController(requireActivity())
        // 再生・一時停止を切り替えるボタン
        binding.play.apply {
            setOnClickListener {
                val state = mediaController.playbackState.state
                if (state == PlaybackStateCompat.STATE_PLAYING) {
                    mediaController.transportControls.pause()
                } else {
                    mediaController.transportControls.play()
                }
            }
        }
        // 操作の監視(サービス接続後なら、ここじゃなくてもOK)
        mediaController.registerCallback(controllerCallback)
    }
}

これで、プレイヤーの変化に合わせてUIが更新されるようになりました。

一旦おわり・まとめ

ここから通知への表示・最初に記載したケースへの対応などを記載予定でしたが、長くなってしまったので別記事に分けたいと思います。

MediaSessionを利用して基本的な操作ができるようになるまで を解説いたしました。

一見すると複雑ですが、

  • ServiceはMediaSessionCompatMediaPlayerを保持して、プレイヤー操作・状態の更新を行う
  • UIはMediaBrowserCompatで接続して、MediaControllerCompatに操作を依頼する
  • UIはMediaControllerCompat.Callbackで再生情報の変化を監視する

という流れになります。

一度作ると流れが分かるのでオススメです!