Androidでリモートコントローラにリストを表示する際のポイント

# 概要

ここでは、Android音楽プレイヤーを作る際、メディアの一覧表示に対応したリモートコントローラーにメディア一覧を表示するための情報について書きます。

画像は、Kenwood MDV-Z904W というカーナビの画面で、Play Musicを動かしたときに見られるリストの表示サンプルです。

![IMG20191107210704.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78503/6305932c-1c56-0074-bec6-8e6baaf96855.jpeg)

# 基本情報

## 対応機器

ここで述べるメディア一覧を表示できるデバイスですが、その特性上、高い解像度のディスプレイが付いている高機能な物に限定されてきます。具体的には、カーナビ、スマートウォッチなどが該当します(なので、それほど使う人は多くないんじゃないかと思っています)。ここでは、自分の持っているカーナビでの動作を想定しています。

## AndroidのMediaSession

Androidで音楽再生アプリ開発を行う場合、MediaSessionを使って音楽情報を管理する方法が提供されています(Android 5以降)。

[メディア アプリ アーキテクチャの概要  \|  Android Developers](https://developer.android.com/guide/topics/media-apps/media-apps-overview)

MediaSessionはAndroidデバイスにおけるメディア情報の入出力や、メディア再生のコントロールを行います。これを利用することで、ロックスクリーン、通知欄、Bluetoothデバイスなどに対して音楽情報を表示したり、外部からのコントロールを受け付けてメディアの操作を行うことができます。

これの利点は、コントローラの差異を吸収してくれる点です。例えば、再生/停止ボタンの操作を受け付ける場合を考えても、画面操作、通知領域操作、Bluetooth、USB、イヤホンのボタン、といった多数の箇所から発生する可能性が考えられますが、それらを統一したインターフェースでコントロールすることができるようになります。また、画面、通知領域、リモコンなどのディスプレイに表示する楽曲情報をまとめて管理することで、どこに対しても同じ情報を表示させることができるようになります。
もしMediaSessionが無ければそれらに対して個別にコードを書く必要が出てくるため、実に合理的な仕組みです。

Androidのメディアプレイヤーアプリは一般的に、この仕組を用いて実装されます。

## AVRCP 1.4

BluetoothにはAVRCP(Audio/Video Remote Control Profile)というプロファイルがあり、一般的なBluetoothコントローラは、これを使ってメディアの再生・停止といった操作や、メディア情報の取得を行います(ちなみに、音声の転送はA2DPという別のプロファイルになります)。このAVRCPには、Bluetoothのバージョンとは別にいくつかのバージョンがあり、次のような違いがあります。上位のバージョンは下位のバージョンの機能を含む上位互換性があります。

1.0: 再生・停止、曲送り・曲戻し、早送り・早戻しなどの操作
1.3: 曲名などの表示
1.4: フォルダやトラック情報の表示、選択
1.5: 微修正？
1.6: 画像転送、メディア数カウント

(参考: [AUDIO/VIDEO REMOTE CONTROL PROFILE](https://www.bluetooth.org/docman/handlers/DownloadDoc.ashx?doc_id=292286))

ここで注目するのは、1.4から追加されたトラック情報の表示機能です。これに対応した機器同士であれば、メディアの一覧を表示することができます。

Androidは8.0からAVRCP1.4に対応しています(参考: [Bluetooth Services  |  Android Open Source Project](https://source.android.com/devices/bluetooth/services#bluetooth-profiles))。そこで、ここではAndroid 8以降のAndroid端末と、AVRCP1.4に対応した機器を繋いで、トラック一覧を表示させることにします。

ちなみに、Android 8.0以降であれば開発者オプションからAVRCPのバージョンを切り替えることができます。(切り替えると接続が安定しなくなるという報告もチラホラあるようですが。)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78503/a3c039b1-48ba-e048-6ddc-53b9df68704b.png" width="240" />


# 実装

## MediaBrowserServiceの実装ポイント

まずアプリの設計として、MediaBrowserService(MediaBrowserServiceCompat)を実装して、その上でメディアの再生を行うようにします。ここで実装の詳細は書きませんが、以下のページ等を参照します。

[Building a media browser service  \|  Android Developers](https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice)
[メディアアプリのアーキテクチャの概要  \|  Android デベロッパー  \|  Android Developers](https://developer.android.com/guide/topics/media-apps/media-apps-overview?hl=ja)

ここでのポイントをいくつか。

### android:exported="true"

MediaBrowserServiceをAndroidManifest.xmlで定義する場合、必ずandroid:exported="true"を付けます。これが無いと、外部からの操作要求を受け付けられません。

```xml:AndroidManifest.xmlの記述
        <service
            android:name=".module.player.MediaPlayerService"
            android:exported="true"
            android:foregroundServiceType="mediaPlayback"
            >
            <intent-filter>
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>
```

### onGetRoot / onLoadChildren

MediabrowserServiceを継承すると、onGetRootとonLoadChildrenの2つのメソッドを実装する必要がありますが、これらが外部からリストを要求された時に呼ばれるメソッドです。
MediaBrowser.MediaItem(MediaBrowserCompat.MediaItem)のリストを返すと、呼び出し元にリストが表示されます。onGetRootが初期リスト、onLoadChildrenは子ノードのリストを返します。これらをカスタマイズすることで、呼び出し元に柔軟なリスト表示を行うことができます。余談ですが、戻り値でリストを返すのではなくResultに値をセットすることでリストを返すので、リストの生成に時間がかかる場合は非同期で返すこともできます。
本記事冒頭のサンプル画像を例に上げると、onGetRootに対して「アーティスト」「アルバム」「曲」「ジャンル」「作曲者」というディレクトリのリストを返していることになります。

### MediaBrowser.MediaItemに余計なデータを含めない

これは自分が少し詰まったポイントです。
リストを返す場合、MediaBrowser.MediaItem(MediaBrowserCompat.MediaItem)のリストを返します。このデータは、初期化時にMediaDescription(MediaDescriptionCompat)を引数に取りますが、これに余計なデータが入っていると結果を返す処理が失敗します。自分の場合、(妙な実装ですが…)MediaDescriptionにMediaMetadataCompatのデータを含んでいたのですが、その場合に以下のようなエラーが発生しました。

```
2019-11-10 14:19:09.878 4737-4769/? V/Avrcp: Enter getFolderItemsRequestFromNative
2019-11-10 14:19:09.878 4737-4769/? V/Avrcp: Exit getFolderItemsRequestFromNative
2019-11-10 14:19:09.880 4737-4864/? E/Parcel: Class not found when unmarshalling: android.support.v4.media.MediaMetadataCompat
    java.lang.ClassNotFoundException: android.support.v4.media.MediaMetadataCompat
        at java.lang.Class.classForName(Native Method)
        at java.lang.Class.forName(Class.java:453)
        at android.os.Parcel.readParcelableCreator(Parcel.java:2827)
        at android.os.Parcel.readParcelable(Parcel.java:2781)
        at android.os.Parcel.readValue(Parcel.java:2684)
        at android.os.Parcel.readArrayMapInternal(Parcel.java:3051)
        at android.os.BaseBundle.unparcel(BaseBundle.java:257)
        at android.os.BaseBundle.getString(BaseBundle.java:1086)
        at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getAttrValue(BrowsedMediaPlayer.java:712)
        at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsFilterAttr(BrowsedMediaPlayer.java:671)
        at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsVFS(BrowsedMediaPlayer.java:527)
        at com.android.bluetooth.avrcp.Avrcp.handleGetFolderItemBrowseResponse(Avrcp.java:3381)
        at com.android.bluetooth.avrcp.Avrcp.-wrap34(Unknown Source:0)
        at com.android.bluetooth.avrcp.Avrcp$AvrcpMessageHandler.handleMessage(Avrcp.java:1114)
        at android.os.Handler.dispatchMessage(Handler.java:105)
        at android.os.Looper.loop(Looper.java:164)
        at android.os.HandlerThread.run(HandlerThread.java:65)
     Caused by: java.lang.ClassNotFoundException: android.support.v4.media.MediaMetadataCompat
        at java.lang.Class.classForName(Native Method)
        at java.lang.BootClassLoader.findClass(ClassLoader.java:1355)
        at java.lang.BootClassLoader.loadClass(ClassLoader.java:1415)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:312)
        at java.lang.Class.classForName(Native Method) 
        at java.lang.Class.forName(Class.java:453) 
        at android.os.Parcel.readParcelableCreator(Parcel.java:2827) 
        at android.os.Parcel.readParcelable(Parcel.java:2781) 
        at android.os.Parcel.readValue(Parcel.java:2684) 
        at android.os.Parcel.readArrayMapInternal(Parcel.java:3051) 
        at android.os.BaseBundle.unparcel(BaseBundle.java:257) 
        at android.os.BaseBundle.getString(BaseBundle.java:1086) 
        at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getAttrValue(BrowsedMediaPlayer.java:712) 
        at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsFilterAttr(BrowsedMediaPlayer.java:671) 
        at com.android.bluetooth.avrcp.BrowsedMediaPlayer.getFolderItemsVFS(BrowsedMediaPlayer.java:527) 
        at com.android.bluetooth.avrcp.Avrcp.handleGetFolderItemBrowseResponse(Avrcp.java:3381) 
        at com.android.bluetooth.avrcp.Avrcp.-wrap34(Unknown Source:0) 
        at com.android.bluetooth.avrcp.Avrcp$AvrcpMessageHandler.handleMessage(Avrcp.java:1114) 
        at android.os.Handler.dispatchMessage(Handler.java:105) 
        at android.os.Looper.loop(Looper.java:164) 
        at android.os.HandlerThread.run(HandlerThread.java:65) 
     Caused by: java.lang.NoClassDefFoundError: Class not found using the boot class loader; no stack trace available
```

## MediaSessionの実装ポイント

MediaSessionによって、外部からのコントロールに対する処理を実装することができます。リストの操作に対する処理もここで定義します。その際のポイントを次に示します。

### setQueue

onGetRoot/onLoadChildrenで返したリストとは別に、MediaSessionにはキューのリスト保持することができます(MediaSession.setQueueで設定できます)。これは再生管理に利用したりしますが、Wear OSはこれに基づいて画面にリストを表示しているようです(多分)。
onGetRoot/onLoadChildrenはディレクトリで階層構造が表現できるのに対し、setQueueではメディアのリストしか定義できません。なので、両方に対応する場合は表示方法を工夫する必要があるかと思います。

### onPlayFromMediaId と onSkipToQueueItem

MediaSession.Callback(MediaSessionCompat.Callback())によって、外部からの操作に対する処理を実装することができます。リスト上でメディアを選択した場合の処理を実装しようと思った場合、onPlayFromMediaIdとonSkipToQueueItemという、よく似た2種類のメソッドがあることに気づきます。
onGetRoot/onLoadChildrenのリストに対して、ユーザが項目の選択操作を行うと、onPlayFromMediaIdが呼ばれます。該当する項目のMedia ID (MediaDescriptionで定義されているID)がパラメータとして渡されるので、それに基づいてメディアの読込や再生といった処理を実装します。Bluetoothデバイスからのリスト選択操作の場合は、こちらが呼ばれます。
一方で、onSkipToQueueItemはMediaSessionが保持しているキューを選択した場合に呼ばれるメソッドになります。Ware OSからのリスト選択操作の場合は、こちらが呼ばれます。パラメータとして渡されるのは、キューを登録する時に設定したIDになります。
両方ともだいたい同じ物なので、両方で同じ実装しておくのが無難だと思います。


## 実行時のポイント

実行時に注意するポイント(自分が詰まったポイント)について書きます。

### 対応デバイスを使う

繰り返しになりますがリスト対応には、端末と接続先のデバイスの両方でBluetoothのAVRCP1.4対応が必要です(Ware OSの場合は状況が違うかもしれないですが、詳細不明)。なので、事前にスペックをよく確認しておきます。Andoridは8.0からOSレベルで対応しているので、端末側はそれ以降を使っておけば間違いないと思います。

### 再接続

開発中の話ですが、一度未対応の状態で繋がってしまうと、再接続するまでリストが表示されませんでした。恐らくBluetoothのセッションが確立した段階で対応有無が確立するのだと思います。なので、対応がうまくいかない場合は一旦Bluetoothを切断して接続し直すと改善されるかもしれません。

### リスト表示方法の確認

基本的な話として、リストが表示できる場合に画面側にどう表示されるかは事前に確認しておくと良いです。例えば、自分のカーナビであれば、リスト表示可能な場合は再生画面の右下「リスト」という項目が増えて、ここを押すとリストが表示されます。Google Play Musicアプリなどはリストに対応しているので、こちらで事前に表示方法を確認しておくと良いです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78503/623db4c4-2dab-1f13-d160-9c86bbb3369e.jpeg" width="360" />

# まとめ

以上が、リモートコントローラにリストを表示する際に注意するポイント(というか、自分が詰まったポイント)になります。これを実装したところで、対応可能な状況はとても限られているため、苦労して実装する割に使い所は少ないです。
そのためかネットを検索しても情報があまり出てこなくて、思ったより実装で詰まるポイントが多かったです。せっかくそれだけ苦労したので、ここでざっくりとまとめてみました。どなたかの参考になれば幸いです。

# その他

余談ですが、MediaSessionには画像を設定する方法が用意されているのですが、カーナビ側にアルバム画像が表示されません。仕様上はBluetoothのAVRCP 1.6以降であれば表示できる気がするのですが、うまくいかないです。iOS側でも駄目なので、恐らくカーナビ側が対応していないためだと思っているのですが、その辺がよく分からず…。

# 関連リンク
[メディア アプリ アーキテクチャの概要  \|  Android Developers](https://developer.android.com/guide/topics/media-apps/media-apps-overview)
[ゼロから学ぶメディアプレイヤーの実装 ｜ Developers\.IO](https://dev.classmethod.jp/smartphone/android/how-to-android-media-player/)

# 追記

OPPO Reno A で色々試してみたのですが、結局カーナビでリストを表示させることができませんでした。デバイスの相性なのか、あるいは実装の不備なのか分かりませんが、ともあれデバイスによってはそもそも上手く動作しない場合があるようです。手持ちのデバイスが対応しているかどうかは事前に確認しておくことが望ましいかと思います。