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

おまけ記事

こちらの記事は、ゼロから学ぶメディアプレイヤーの実装の続きとなります。

MediaSessionを利用した操作が前提になりますので、ご注意ください。

通知に表示する

よくあるこれです

通知を出すだけなので、特に変わったことは無いです。通知内のボタン設定がちょっと違います。

import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat as MediaNotificationCompat

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var notificationManager: NotificationManagerCompat

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

        override fun onPlay() {
            notificationManager.notify(1, buildNotification())
        }

        override fun onPause() {
            notificationManager.notify(1, buildNotification())
        }
    }

    override fun onCreate() {
        super.onCreate()
        notificationManager = NotificationManagerCompat.from(baseContext)
    }

    private fun buildNotification(): Notification {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            createNotificationChannel()
        }
        val currentDescription = mediaSession.controller.metadata.description
        val notificationBuilder = NotificationCompat.Builder(baseContext, NotificationConst.CHANNEL_MUSIC)
                .setStyle(MediaNotificationCompat.MediaStyle()
                        .setMediaSession(mediaSession.sessionToken)
                        .setShowActionsInCompactView(0)
                )
                .setColor(getColor(R.color.colorPrimary))
                .setSmallIcon(R.drawable.ic_library_music_black_24dp)
                .setLargeIcon(currentDescription.iconBitmap)
                .setContentTitle(currentDescription.title)
                .setContentText(currentDescription.subtitle)
                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

        val action = if (mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_PLAYING) {
            NotificationCompat.Action(
                    R.drawable.ic_notification_pause,
                    getString(R.string.action_pause),
                    MediaButtonReceiver.buildMediaButtonPendingIntent(baseContext, PlaybackStateCompat.ACTION_PAUSE)
            )
        } else {
            NotificationCompat.Action(
                    R.drawable.ic_notification_play_arrow,
                    getString(R.string.action_play),
                    MediaButtonReceiver.buildMediaButtonPendingIntent(baseContext, PlaybackStateCompat.ACTION_PLAY)
            )
        }
        notificationBuilder.addAction(action)
        return notificationBuilder.build()
    }

    @RequiresApi(Build.VERSION_CODES.O)
    private fun createNotificationChannel() {
        val manager = getSystemService(NotificationManager::class.java)
        if (manager.getNotificationChannel(NotificationConst.CHANNEL_MUSIC) == null) {
            val channel = NotificationChannel(
                    NotificationConst.CHANNEL_MUSIC,
                    getString(R.string.channel_music_title),
                    NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = getString(R.string.channel_music_description)
            }
            manager.createNotificationChannel(channel)
        }
    }

クラス名が同じなのでimportは注意!(何で同じにしたんだ)

import androidx.core.app.NotificationCompat
import androidx.media.app.NotificationCompat as MediaNotificationCompat

通知のボタンActionに設定するIntentを生成します。

MediaButtonReceiver.buildMediaButtonPendingIntent(baseContext, <PlaybackStateCompatの対応ACTION>)

ボタン操作を受け取れるようにします。

@AndroidManifest.xml

<receiver android:name="androidx.media.session.MediaButtonReceiver">
    <intent-filter>
        <action android:name="android.intent.action.MEDIA_BUTTON" />
    </intent-filter>
</receiver>

見た目の設定は以下のようになります。

val notificationBuilder = NotificationCompat.Builder(baseContext, NotificationConst.CHANNEL_MUSIC)
        .setStyle(MediaNotificationCompat.MediaStyle()
                .setMediaSession(mediaSession.sessionToken)
                .setShowActionsInCompactView(0)
        )

NotificationCompat.MediaStyleを利用します

setMediaSession(mediaSession.sessionToken)を忘れないようにしましょう。

setShowActionsInCompactViewは通知を折りたたんだ状態でも表示するボタンを設定しています。(3つまで)※addActionした順でindex値を決めます、例の場合、最初に追加したボタンを対象としています。

折りたたんだ状態と比べてみます

また、再生状態が変わった際に通知内のボタン表示を切り替えたいので、onPlay/onPauseなどで通知を更新します。※notify時のidが同じなら更新されます。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private val callback = object : MediaSessionCompat.Callback() {
        override fun onPlay() {
            notificationManager.notify(1, buildNotification())
        }

        override fun onPause() {
            notificationManager.notify(1, buildNotification())
        }
        ...

アプリを閉じても再生を続ける

アプリを終了させてもMediaPlayerを継続させるには、MediaPlayerをServiceで管理する必要があります。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private val mediaPlayer = MediaPlayer()

    ...

このままでは破棄されてしまうので、再生中は破棄できないようにしてみましょう。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var notificationManager: NotificationManagerCompat

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

        override fun onPlay() {
            startService(Intent(baseContext, MediaPlaybackService::class.java))
            startForeground(1, buildNotification())
        }
        ...

これで、通知をスワイプしても消えないことを確認してください。

では、一時停止したら、通知を消せるようにします。

class MediaPlaybackService : MediaBrowserServiceCompat() {

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

        override fun onPause() {
            stopForeground(false)
        }
        ...

通知が自動で削除されると一時停止から再生できなくなってしまうので、stopForegroundにはfalseを指定します。

これで、一時停止中に通知を消せるようになります。

ループ再生させる

プレイヤーの設定

MediaPlayerで1曲ループさせる場合はmediaPlayer.isLooping = trueを設定します。今回はMediaSessionを経由させたいので、以下のように設定します。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var mediaSession: MediaSessionCompat

    private val callback = object : MediaSessionCompat.Callback() {
        override fun onSetRepeatMode(repeatMode: Int) {
            mediaSession.setRepeatMode(repeatMode)
            when (repeatMode) {
                PlaybackStateCompat.REPEAT_MODE_ONE -> {
                    mediaPlayer.isLooping = true
                }
                else -> {
                    mediaPlayer.isLooping = false
                }
            }
        }
    }
    ...

MediaSessionCompat.Callback#onSetRepeatMode(int)を実装してあげれば良いです。

mediaSession.setRepeatMode(repeatMode)を行うことで、UI側に現在の状態を教えてあげます。

UI切り替え

ループボタンをトグルさせてみましょう。

class MediaPlayerFragment : Fragment() {

    private val controllerCallback = object : MediaControllerCompat.Callback() {
        override fun onRepeatModeChanged(repeatMode: Int) {
            when (repeatMode) {
                PlaybackStateCompat.REPEAT_MODE_ONE -> {
                    binding.repeat.apply {
                        setImageResource(R.drawable.ic_repeat_one_black)
                        imageTintList = ColorStateList.valueOf(resources.getColor(R.color.playerButton, null))
                    }
                }
                else -> {
                    binding.repeat.apply {
                        setImageResource(R.drawable.ic_repeat_black)
                        imageTintList = ColorStateList.valueOf(resources.getColor(R.color.playerButtonSecondary, null))
                    }
                }
            }
        }
    }
    ...

MediaControllerCompat.Callback#onRepeatModeChanged(int)を使って、プレイヤー側で設定した値を取得しUIに反映させます。

これで、Fragment以外から操作された場合でも、画面が更新されるようになりました。(※MediaSessionを使った操作であれば)

こちらは画像では伝わりづらいと思うので、実際に試してみてください。短めの曲を使ったり、シーク機能を用意すればループされることが簡単に確認できます。

ヘッドホンのボタンで操作してみる

再生・一時停止は既にできるようになっています。

※minSdk 21未満の場合は、ボタンのイベントを受け取る必要があります。必要な方はHandling media buttons in a foreground activityを参照。

実用的ではありませんが、ヘッドホンで次曲スキップの操作をしたら、ループをONにしてみましょう。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private val callback = object : MediaSessionCompat.Callback() {
        override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
            val keyEvent = mediaButtonEvent.getParcelableExtra<KeyEvent?>(Intent.EXTRA_KEY_EVENT)
            if (keyEvent == null || keyEvent.action != KeyEvent.ACTION_DOWN) {
                return false
            }
            return when (keyEvent.keyCode) {
                KeyEvent.KEYCODE_MEDIA_NEXT,
                KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD -> {
                    onSetRepeatMode(PlaybackStateCompat.REPEAT_MODE_ONE)
                    true
                }
                else -> super.onMediaButtonEvent(mediaButtonEvent)
            }
        }
    }

※エミュレータで試したい場合は adb shell input keyevent 87 で試せます。

余談

他のミュージックプレイヤーが起動していたら、どっちが反応するんだ?と思った方はFinding a media sessionを読むと良いです。Oreo以上か、未満かで動作が変わってきます。

起動していない時にヘッドホンで再生操作したら前に再生していた所から続けたい みたいな要件が出てくると、必要になると思います。

電話が掛かってきたら止める

電話が掛かってきたら、再生を一時停止します。電話中のBGMアプリだったら対応不要ですxD

Audio focus

AndroidではAudio Focusという概念があります。音声を出力するアプリは、これを無視できません。(しないで)

音声を出すアプリは1つにフォーカスさせようという感じ。

これに対応することで、「電話がフォーカスを得ようとしたら、メディアプレイヤーが一時停止する」といった仕様を実現できます。

プレイヤーの実装

では実際に対応してみましょう。

まずはフォーカスを得るためのリクエストを作ります

private val audioFocusRequest = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
        .setAudioAttributes(AudioAttributesCompat.Builder()
                .setUsage(AudioAttributesCompat.USAGE_MEDIA)
                .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
                .build()
        )
        .setOnAudioFocusChangeListener { focusChange ->
            when (focusChange) {
                AudioManager.AUDIOFOCUS_GAIN -> {
                    mediaSession.controller.transportControls.play()
                }
                AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
                    mediaSession.controller.transportControls.pause()
                }
                AudioManager.AUDIOFOCUS_LOSS -> {
                    mediaSession.controller.transportControls.stop()
                }
            }
        }
        .build()

AudioFocusRequestCompat.Builderには以下のいずれかを渡します。

  • AudioManagerCompat.AUDIOFOCUS_GAIN
    • フォーカス取得後、破棄するタイミングが不定の場合に使います。今回はこちらを利用。
  • AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT
    • 一時的にフォーカスを取得したい場合に利用します。通知音を出す場合など。
  • AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
    • 一時的にフォーカスを取得しますが、他のアプリも音量を下げた状態で音を再生できます。
    • 他のアプリが音を再生している時、止めずにこちらのアプリが音を出したい場合に使えます。
  • AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
    • 一時的にフォーカスを取得したい場合に利用しますが、こちらは他の音声再生を許可しません。
    • 他の音声が混じってほしくないケースで使えそうです。(ボイスメモなど)

と、フラグは用意されていますが、これを見て再生の制御をするのは開発者の作業となります。duckに対応していないアプリで音を出しているときに、こちらがduckでリクエストしても意味がありません。

setAudioAttributesでフォーカス取得用途を設定します。今回は音楽再生なので上記のように設定しました。こちらも正しく設定してあげましょう。

最後にsetOnAudioFocusChangeListenerでフォーカス状態の切り替わりをチェックします。

定数が多いので何があるかはAudioManager#AUDIOFOCUS_GAINあたりを参照してください。

サンプルでは以下に対応します。

  • AudioManager.AUDIOFOCUS_GAIN
    • フォーカスを得る(返ってくる)ので、再生します。
  • AudioManager.AUDIOFOCUS_LOSS_TRANSIENT
    • フォーカスを一時的に失うので、一時停止にしておきます。
    • フォーカスが返ってくると、再生されます。
  • AudioManager.AUDIOFOCUS_LOSS
    • フォーカスを失ってしまったので、再生を止めてしまいます。
    • ユーザーは再度アプリに戻って、自分で再生ボタンを押す必要があります。(再度gainする)
    • サンプルではstopしてしまうので、また最初から再生されてしまいます(ダサい)

完全に停止させるのではなく、一時停止&通知を消す といった対応をするとより良いプレイヤーになるでしょう。

次に再生ボタンを押したら、実際にフォーカスをリクエストしましょう。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var audioManager: AudioManager

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

        override fun onPlay() {
            if (gainAudioFocus()) {
                // play
            }
        }
    }

    override fun onCreate() {
        super.onCreate()
        audioManager = getSystemService(AudioManager::class.java)
    }

    private fun gainAudioFocus(): Boolean {
        return when (AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest)) {
            AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> true
            AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
                Timber.w("requestAudioFocus failed.")
                false
            }
            else -> false
        }
    }
}

あまり解説するような箇所はありません、フォーカスを取得できたら再生開始処理を行いましょう。着信中・通話中などフォーカス取得できないケースもあるようです。

リクエスト時は、OnAudioFocusChangeListenerにAudioManager.AUDIOFOCUS_GAINは渡されません。リスナに渡される値は、リクエスト後にフォーカスが変わった時の動作になるので、注意しましょう。

これでフォーカスの取得までできました。あとはフォーカスのチェックが不要になったら破棄してあげます。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private lateinit var audioManager: AudioManager

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

        override fun onStop() {
            AudioManagerCompat.abandonAudioFocusRequest(audioManager, audioFocusRequest)
        }
        ...

これで対応できました。

実際にアプリで音楽を再生し、YouTubeを再生すると、AudioManager.AUDIOFOCUS_LOSSの状態となり停止します。

また、電話が掛かってくるとAudioManager.AUDIOFOCUS_LOSS_TRANSIENTになり一時停止。通話が終了すると、AudioManager.AUDIOFOCUS_GAINの状態へ移行するので、再開されます。

SDK的には対応できるようになってはいますが、実際に対応するかはアプリの責任となっているので、お行儀の良いアプリ設計を心がけましょう!!

イヤホンが抜けたらスピーカーで鳴らないように止める

こちらもメディアプレイヤーなら必須の対応です。

ヘッドホンとスピーカーの音量が別々で設定されてれば問題ないですが、メディア音量として一緒くたにされている場合もあります。このような場合に対応していないと、イヤホンが抜けたりヘッドホンの接続が切れた場合に、爆音で再生されてしまいます。

対応は簡単なので、忘れないようにしましょう!

では実装していきます。

何らかの理由でイヤホン等からスピーカーに出力されるAudioManager.ACTION_AUDIO_BECOMING_NOISYが飛ばされます。 再生中に受け取ったら一時停止するようにしてみます。

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private val audioNoisyFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)

    private val audioNoisyReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            mediaSession.controller.transportControls.pause()
        }
    }

フィルタの用意はこれで完了です。再生時にON、それ以外でOFFにしましょう。

class MediaPlaybackService : MediaBrowserServiceCompat() {

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

        override fun onPlay() {
            if (gainAudioFocus()) {
                registerReceiver(audioNoisyReceiver, audioNoisyFilter)
            }
        }

        override fun onPause() {
            unregisterReceiver(audioNoisyReceiver)
        }

        override fun onStop() {
            unregisterReceiver(audioNoisyReceiver)
        }
        ...

これで完了です。エミュレータで確認することも出来ます。

Virtual headset plug insertedをONからOFFにしたとき、一時停止されます。

おわりに

いかがでしたか?メディアプレイヤーを作る際に知っておくべき基本を学習しました。ひとつひとつの対応は難しくないのですが、全て盛り込もうとするとコードが複雑になっていきます。プレイヤーを操作する部分は別クラスに分けるなどするとより良い設計になります。お試しください。

今回の解説には含めませんでしたが、スキップやシャッフルなども基本は同じです。MediaSessionCompat.Callbackにて操作を受け取って、MediaPlayerに対して処理を行ってあげましょう!