Java
Android
AndroidWear
Exoplayer
MediaSession

イマドキなAndroid音楽プレーヤーの作り方

こんなものを作ります

sc1.png
とても一般的な音楽プレーヤーの雛形を作ります。
 
cap2.gif

また、専用の処理を実装しなくてもWearやAutoに対応できる方法を取ります。

Androidの音楽プレイヤーに関するの日本語記事って皆無ですよね。
色々苦労してなんとか実装できたので記事にしました。
長くなりますが、よければお付き合いください。

実装だけ見たい方は、いざ実装まで飛ばしてください。

Android上で動く音楽プレーヤーのあるべき姿

PCで動く音楽プレイヤーを作る時は、MediaPlayer等のインスタンスの各種メソッドを
Formに貼り付けたボタンのクリックに応じて呼び出すのが主な作り方だったと思います。

fig1.png

しかし、近年のAndroidやiOSなどのモバイルプラットフォームはスマートフォンにとどまらず
腕時計やカーオーディオ、テレビなどの様々な機器に組み込まれるようになりました。
するとスマホとリンクして、スマートウォッチやカーオディオからプレイヤーを操作したり、曲の情報を表示することが求められるようになりました。
もし開発者が様々なデバイス専用のコード(Android Wear用、Auto用、TV用など)を書くことになると大変な負担になります。
そこでAndroidシリーズでそういった処理を共通化してしまおうという動きが起こり、Android5.0で実装されました。
それがMediaSessionです。

Media Session の立ち位置、役割

fig2.png

Androidの音楽周りの仕組みを表したのが上の図になります。
これらはサーバー・クライアントの関係と似ています。

Media Sessionはサーバーの役割を果たします。
曲を操作をする機能や、プレイヤーの状態や曲の情報などを接続されたクライアントに提供します。

クライアントはMedia Sessionに対してMedia ControllerMedia Button Intentなどを利用して操作をリクエストしたり
通知されるプレイヤーの状態や曲の情報を表示します。

参考:
Understanding MediaSession (Part 1/4)
Understanding MediaSession (Part 2/4)

MediaControllerとMedia Button Intent

どちらもMedia Sessionに対して曲の操作(再生、停止、スキップなど)を要求するのに利用します。
違いとしては、Media Button Intentは操作の要求のみできるのに対し、Media Controllerはプレイヤーのステータスや曲の情報も取得できる点です。

ちなみに、Media SessionにはSession Tokenという固有のIDが割り振られます。先程のサーバーの例えを使うならIPアドレスのようなものです。つまりそのTokenがわかっている場合、外部から接続することができます。
接続の際にはMedia ControllerのコンストラクタにTokenを渡します。

Media Sessionの実装例

上の説明だけではMedia Sessionの使い方がイメージしづらいと思うので図にしてみました。
fig3-3.png

図を見るとかなりめんどくさいことをやっているように見えます。しかし、Androidというプラットフォームで音楽プレイヤーを作る際に上の形を取ることは、とても理にかなっています。

普段みなさんが使っている音楽プレイヤーは、Activityを最近のタスクから消しても再生し続けると思います。
あるいはタスクから消さなくても、別のアプリに移動することでメモリ不足時にActivityが破棄されるかもしれません。
つまり再生に関わる処理をActivityに一部でも任せているとActivityが破棄された時点で再生を続行することができなくなるのです。

よって、Android上で動く音楽プレイヤーを作る上で重要な考え方は、曲を再生する機能は全てService側に実装し、Activity側はあくまでユーザーの操作に従って再生、一時停止などの操作をMedia Sessionにリクエストし、Media Sessionから送られてくる再生中の曲情報などをUIに表示するだけ、というように役割分担を明確にすることです。

Activityはあくまでユーザに曲情報を表示し、プレイヤーを制御するコントロールを提供するだけであると割り切ることで、Activityが破棄されることを恐れる必要がなくなります。
Activityが再生成された時も、Media Sessionに再度接続し直すだけです。

参考:Media Apps Overview

Media SessionとMedia Browser

前例では普通のServiceにMedia Sessionを実装していました。
しかし、実際はServiceを拡張したMedia Browser Serviceを使用して実装することが推奨されています。

先程のMedia Sessionは曲の操作や情報を提供するサーバーのような役割で、Media Browser Serviceは音楽ライブラリを提供する役割を果たします。Media Browserの仕様に則って実装すれば自動的にWearやAutoからも曲を選択するなどの機能が使えるようになります。
また、バインドや、Session Tokenを取得すると言った操作もカプセル化してくれます。

fig4.png
①②の手順の名前がわかりやすくなっている程度で前例と大差ないように見えます。
実際はここに曲のリストを提供する機能などが追加されます。

参考:
Building a Media Browser Service
Building a Media Browser Client

Media Browserの設計概念に則ったライブラリのブラウジング

次はライブラリのブラウジングについてです。
要するに再生できる曲の一覧をユーザーに表示する機能です。

前例の④で再生したい曲を伝えていますが、そもそも曲のリストが無いとリクエストのしようがありません。
前述の通りMedia Browserは音楽ライブラリを提供する機構を備えています。

fig6.png

上の図はMedia Browser Serviceに接続して曲のリストを取得する過程を簡単に表したものです。
Media BrowserからMedia Browser ServiceにMediaItemを要求するにはsubscribe(MediaId)を呼び出します。
そしてMediaItemのリストが帰ってきます。③④の工程は必要な時に、その都度実行します。

Media IDの概念

Media Browser Serviceでは曲でもアルバムでもジャンルでも要素全てをMediaIdという文字列で表します。
そしてそのMediaIdと、付随するデータを保持するクラスにMediaBrowser.MediaItemがあります。

MediaItemが保持している内容に、それが子要素をもっているか、いわゆるフォルダの役割を持つItemなのか、子を持っていない、再生可能な音楽Itemなのかを識別するフラグを持っています。
以下の図を見てください。

fig5.png

オレンジがフォルダのフラグを持っているItemで、青が再生可能なフラグを持っているItemです。
書かれている文字列はMediaIdを示しています。

ここで誤解しないでほしいのは、フォルダフラグの立っているのMediaItem自体が実際に子要素をオブジェクトとして持っているわけではないのです。上の図ではツリー構造のように書きましたが、オブジェクト的には親子関係が無いのです。

 
わかりにくいと思うので、具体例を上げてみましょう。
Media Browserのsubscribe(mediaId)を呼び出すと接続先のMedia Browser ServiceonLoadChildren()が呼び出され、そこから送信された内容が返されます。
初めはsubscribe("root")を呼び出します。
すると{"Genre","Album"}が帰ってきます。
ユーザーはジャンル別にみたいと思ったとします。
そうしたらsubscribe("Genre")を呼び出します。すると{"Genre:Rock,"Genre:Jazz"}が帰ります。
その後subscribe("Genre:Rock")と進むと、{"music1","music2"}が帰ってきます。
ユーザーがmusic1を選択するとMedia ControllerからMediaIdが"music1"の曲を再生するように要求します。

あたかもsubscribe("Genre:Rock")を呼び出したら自動でmusic1とmusic2が帰ってくるように書きましたが、その機構は自分で書く必要があります。例えばMediaIdの頭に"Genre:"がついていたらそれは特定のジャンルの曲を全て列挙するIDとみなし、曲を返すと行った具合です。
つまりMediaIDだけで何を求められているのかを把握し、曲なりサブカテゴリを返す必要があるのです。

しかし今回のサンプルでは、シンプルにrootに2曲ぶら下がっているような感じにします。

曲の情報を格納するクラス一覧

予め用意されているクラスは以下の様なものがあります。

名前 説明
MediaDescription 最低限のメタデータを保持するクラス。
MediaMetadata Media Sessionから配信される曲の情報はこの形式。最低限のメタデータに加え、連想配列のような感じでカスタムデータを設定することができる。getDescription()でMedia Descriptionを生成することもできる。
MediaBrowser.MediaItem MediaBrowserServiceから情報を取得するときの形式。曲の他にジャンルやアルバムなど、子要素を持つアイテムを表現することもできる。 内部にMediaDescriptionを持つ。
MediaSession.QueueItem 内部にMediaDescriptionを持ち、キュー内の曲の情報に使われる。その為インデックス情報がついている。

WearやAutoと連携できる理由

Media Browser ServiceMedia Sessionとの接続にMedia BrowserMedia Controllerを使用することはわかっていただけたと思います。
実はこの2つをWearやAutoも持っています。今までの例ではActivityから接続していましたが、WearやAutoも同じように接続してくるだけなので、専用の処理を書く必要がないのです。

連携例

cap3.gif
Media Sessionから配信される曲の情報が表示される。
また、Media Sessionにキューアイテムを設定しておくと下に曲のリストが表示される。

cap2.gif
Wear側もMediaBrowser.subscribeをしていて、曲のリストを取得し表示してくれる。
(今回は曲のみだが、onLoadChildrenの実装によってはアルバム別やジャンル別のメニューも表示可能)

Audio Focus

Media SessionやBrowserの存在感からすると大したものではありませんが、これも重要な機能です。
Audio FocusとはUIでいうFocusのオーディオ版です。
1つのアプリだけがAudio Focusを持つようにします。そしてAudio Focusを持ったアプリのみが曲を再生するようにすれば、同時に複数のアプリが音楽を再生するのを防ぐことができます。

しかしAndroidシステムは、あくまでFocusが当たったり、外れたりするのを通知してくれるだけなので、フォーカスが外れた(別のアプリの再生が始まった場合など)ら一時停止するなどの処理は自分で書く必要があります。

※Android8.0から仕様が変わったようです。
詳細はManaging Audio Focusをご覧ください。

いざ実装

今回は音楽データはAssetsフォルダ内に入れたものを使います。
ストレージの話が絡んでくると複雑になる気がしたので、Media Sessionを使う練習ということで妥協しました。
また、曲のメタデータもハードコーディングです。ご了承ください。

全てのコードはこちら

ターゲットバージョン

Android8.0で通知チャンネルが追加されて、バージョンで分岐する必要が出てしまうので、今回はターゲットを25に、最低は21にしてます。
(そこがイマドキじゃないというツッコミは受け付けておりません^^;)

使用ライブラリ

MP3を再生するだけなのでMediaPlayerでもいいのですが、自分が普段使っているExoPlayerを使用します。
ExoPlayerとはGoogleが開発しているメディアプレイヤーで、バージョンや機種によって実装が違うことがあるデフォルトのMediaPlayerに代わるように作られたものです。
アップデートがされるため再生できる形式が増えていくほか、ライブストリーミングなどの機能も備えます。
ExoPlayerの使い方は
ExoPlayerの使い方
などが参考になると思います。

また、Support Libraryを使用します。バージョン間の差を吸収してくれるのでGoogle公式も推しています。
そのため、MediaSessionCompatのように後ろにCompatがついているものを使用します。
依存ライブラリは以下になります

build.gradle
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support:design:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.1'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
    implementation 'com.google.android.exoplayer:exoplayer:2.6.1'
}

構造

image.png

・MainActivity
 表示されるAcitivty
・MusicLibrary
 Googleの公式サンプルから、拝借しました。曲の情報がハードコーディングされています。
・MusicService
 Meida Browser Serviceの実装。

以下、コールバックとメソッドの関係図です
fig9.png

AndroidManifest.xml

AndroidManifest.xml
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service android:name=".MusicService">
            <intent-filter>
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>
        <receiver android:name="android.support.v4.media.session.MediaButtonReceiver">
            <intent-filter>
                <action android:name="android.intent.action.MEDIA_BUTTON"/>
            </intent-filter>
        </receiver>
    </application>

Application部分の抜粋です。
MusicServiceを登録しておくのと、MediaButtonIntentを受け取るためのレシーバの設定もしておきます。

MainActivity

UI

fig7.png
 
UIのxmlファイルはこちら

初期化&接続

MainActivity.java
    MediaBrowserCompat mBrowser;
    MediaControllerCompat mController;

    //UI関係は省略

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        //UI系のセットアップ(省略)

        //サービスは開始しておく
        //Activity破棄と同時にServiceも停止して良いならこれは不要
        startService(new Intent(this, MusicService.class));

        //MediaBrowserを初期化
        mBrowser = new MediaBrowserCompat(this, new ComponentName(this, MusicService.class), connectionCallback, null);
        //接続(サービスをバインド)
        mBrowser.connect();
    }

    //接続時に呼び出されるコールバック
    private MediaBrowserCompat.ConnectionCallback connectionCallback = new MediaBrowserCompat.ConnectionCallback() {
        @Override
        public void onConnected() {
            try {
                //接続が完了するとSessionTokenが取得できるので
                //それを利用してMediaControllerを作成
                mController = new MediaControllerCompat(MainActivity.this, mBrowser.getSessionToken());
                //サービスから送られてくるプレイヤーの状態や曲の情報が変更された際のコールバックを設定
                mController.registerCallback(controllerCallback);

                //既に再生中だった場合コールバックを自ら呼び出してUIを更新
                if (mController.getPlaybackState() != null && mController.getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING) {
                    controllerCallback.onMetadataChanged(mController.getMetadata());
                    controllerCallback.onPlaybackStateChanged(mController.getPlaybackState());
                }


            } catch (RemoteException ex) {
                ex.printStackTrace();
                Toast.makeText(MainActivity.this, ex.getMessage(), Toast.LENGTH_LONG).show();
            }
            //サービスから再生可能な曲のリストを取得
            mBrowser.subscribe(mBrowser.getRoot(), subscriptionCallback);
        }
    };

    //Subscribeした際に呼び出されるコールバック
    private MediaBrowserCompat.SubscriptionCallback subscriptionCallback = new MediaBrowserCompat.SubscriptionCallback() {
        @Override
        public void onChildrenLoaded(@NonNull String parentId, @NonNull List<MediaBrowserCompat.MediaItem> children) {
            //既に再生中でなければ初めの曲を再生をリクエスト
            if (mController.getPlaybackState() == null)
                Play(children.get(0).getMediaId());
        }
    };

始めにMusicServiceを開始します。

その後MediaBrowserを初期化し、接続しています。
接続が完了するとonConnected()が呼ばれるので、mBrowser.getSessionToken()でTokenを取得し、MediaControllerのコンストラクタに渡すことでMedia Sessionと接続します。

次にmBrowser.subscribe(mBrowser.getRoot(), subscriptionCallback)を呼び出し、曲のリストを要求します。
Service側がリストを送ってくると設定したコールバックのonChildrenLoaded()が呼び出されるので、今回は始めのアイテムを再生します。

Media Controllerを使用してMedia Sessionと通信

MainActivity.java
    private void Play(String id) {
        //MediaControllerからサービスへ操作を要求するためのTransportControlを取得する
        //playFromMediaIdを呼び出すと、サービス側のMediaSessionのコールバック内のonPlayFromMediaIdが呼ばれる
        mController.getTransportControls().playFromMediaId(id, null);
    }

mController.getTransportControls()でMediaSessionへ操作を要求するために使用するTransportControlを取得できます。

MediaSessionから情報が配信されたときの処理

MainActivity.java
    //MediaControllerのコールバック
    private MediaControllerCompat.Callback controllerCallback = new MediaControllerCompat.Callback() {
        //再生中の曲の情報が変更された際に呼び出される
        @Override
        public void onMetadataChanged(MediaMetadataCompat metadata) {
            textView_title.setText(metadata.getDescription().getTitle());
            imageView.setImageBitmap(metadata.getDescription().getIconBitmap());
            textView_duration.setText(Long2TimeString(metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION)));
            seekBar.setMax((int) metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION));
        }

        //プレイヤーの状態が変更された時に呼び出される
        @Override
        public void onPlaybackStateChanged(PlaybackStateCompat state) {

            //プレイヤーの状態によってボタンの挙動とアイコンを変更する
            if (state.getState() == PlaybackStateCompat.STATE_PLAYING) {
                button_play.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        mController.getTransportControls().pause();
                    }
                });
                button_play.setImageResource(R.drawable.exo_controls_pause);
            } else {
                button_play.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View v) {
                        mController.getTransportControls().play();
                    }
                });
                button_play.setImageResource(R.drawable.exo_controls_play);
            }

            textView_position.setText(Long2TimeString(state.getPosition()));
            seekBar.setProgress((int) state.getPosition());

        }
    };

プレイヤーの状態や、再生する曲が変わった場合に、MediaSessionから情報が送られてきてMediaControllerのコールバックを呼び出します。
その変更をUIに反映します。

MusicService

MediaBrowserServiceを継承したServiceを作成します。

クライアントから接続されたときの処理

MusicService.java
    //クライアント接続時に呼び出される
    //パッケージ名などから接続するかどうかを決定する
    //任意の文字列を返すと接続許可
    //nullで接続拒否
    //今回は全ての接続を許可
    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName,
                                 int clientUid,
                                 Bundle rootHints) {
        Log.d(TAG, "Connected from pkg:" + clientPackageName + " uid:" + clientUid);
        return new BrowserRoot(ROOT_ID, null);
    }

    //クライアント側がsubscribeを呼び出すと呼び出される
    //音楽ライブラリの内容を返す
    //WearやAutoで表示される曲のリストにも使われる
    //デフォルトでonGetRootで返した文字列がparentMediaIdに渡される
    //ブラウザ画面で子要素を持っているMediaItemを選択した際にもそのIdが渡される
    @Override
    public void onLoadChildren(
            @NonNull final String parentMediaId,
            @NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {

        if (parentMediaId.equals(ROOT_ID))
            //曲のリストをクライアントに送信
            result.sendResult(MusicLibrary.getMediaItems());
        else//今回はROOT_ID以外は無効
            result.sendResult(new ArrayList<MediaBrowserCompat.MediaItem>());
    }

onGetRoot()は接続時、onLoadChildren()はsubscribeされた時に呼び出されます。onLoadChildren()は時間がかかるのが前提なのでreturnで返すのではなくresultオブジェクトに対して結果を送るという形式を取っています。すぐに返せる場合はresult.sendResultを、非同期で後から返したい場合はresult.detatch()しておきます。

初期化

MusicService.java
    final String TAG = MusicService.class.getSimpleName();//ログ用タグ
    final String ROOT_ID = "root";//クライアントに返すID onGetRoot / onLoadChildrenで使用

    Handler handler;//定期的に処理を回すためのHandler

    MediaSessionCompat mSession;//主役のMediaSession
    AudioManager am;//AudioFoucsを扱うためのManager

    int index = 0;//再生中のインデックス

    ExoPlayer exoPlayer;//音楽プレイヤーの実体

    List<MediaSessionCompat.QueueItem> queueItems = new ArrayList<>();//キューに使用するリスト

    @Override
    public void onCreate() {
        super.onCreate();
        //AudioManagerを取得
        am = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
        //MediaSessionを初期化
        mSession = new MediaSessionCompat(getApplicationContext(), TAG);
        //このMediaSessionが提供する機能を設定
        mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | //ヘッドフォン等のボタンを扱う
                MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS | //キュー系のコマンドの使用をサポート
                MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); //再生、停止、スキップ等のコントロールを提供

        //クライアントからの操作に応じるコールバックを設定
        mSession.setCallback(callback);

        //MediaBrowserServiceにSessionTokenを設定
        setSessionToken(mSession.getSessionToken());

        //Media Sessionのメタデータや、プレイヤーのステータスが更新されたタイミングで
        //通知の作成/更新をする
        mSession.getController().registerCallback(new MediaControllerCompat.Callback() {
            @Override
            public void onPlaybackStateChanged(PlaybackStateCompat state) {
                CreateNotification();
            }

            @Override
            public void onMetadataChanged(MediaMetadataCompat metadata) {
                CreateNotification();
            }
        });


        //キューにアイテムを追加
        int i = 0;
        for (MediaBrowserCompat.MediaItem media : MusicLibrary.getMediaItems()) {
            queueItems.add(new MediaSessionCompat.QueueItem(media.getDescription(), i));
            i++;
        }
        mSession.setQueue(queueItems);//WearやAutoにキューが表示される


        //exoPlayerの初期化
        exoPlayer = ExoPlayerFactory.newSimpleInstance(getApplicationContext(), new DefaultTrackSelector());
        //プレイヤーのイベントリスナーを設定
        exoPlayer.addListener(eventListener);

        handler = new Handler();
        //500msごとに再生情報を更新
        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                //再生中にアップデート
                if (exoPlayer.getPlaybackState() == Player.STATE_READY && exoPlayer.getPlayWhenReady())
                    UpdatePlaybackState();

                //再度実行
                handler.postDelayed(this, 500);
            }
        }, 500);
    }

量が多いのでかいつまんで説明します。
まずはMediaSessionを初期化した後に行っているmSession.setFlags()について。
フラグを設定することでMediaSessionの機能を有効化することができます。
逆に設定しないと何一つ機能が使えないので注意してください。

次にsetSessionToken(mSession.getSessionToken())
これはクライアント(MediaBrowser)のgetSessionToken()で返される値を設定しています。

また、今回はExoPlayerを使用するため、ExoPlayerの初期化を行います。

最後にhandlerを用いて一定時間ごとに UpdatePlaybackState()を呼び出す処理をしています。
この処理については後で説明します。

クライアントからのMedia Sessionに対する要求を処理する

MainActivityでMediaSessionへの操作要求に使用した、TransportControlと対になっている部分です。
fig8.png

MusicService.java
    //MediaSession用コールバック
    private MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() {

        //曲のIDから再生する
        //WearやAutoのブラウジング画面から曲が選択された場合もここが呼ばれる
        @Override
        public void onPlayFromMediaId(String mediaId, Bundle extras) {
            //今回はAssetsフォルダに含まれる音声ファイルを再生
            //Uriから再生する
            DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(getApplicationContext(), Util.getUserAgent(getApplicationContext(), "AppName"));
            MediaSource mediaSource = new ExtractorMediaSource.Factory(dataSourceFactory).createMediaSource(Uri.parse("file:///android_asset/" + MusicLibrary.getMusicFilename(mediaId)));

            //今回は簡易的にmediaIdからインデックスを割り出す。
            for (MediaSessionCompat.QueueItem item : queueItems)
                if (item.getDescription().getMediaId().equals(mediaId))
                    index = (int) item.getQueueId();

            exoPlayer.prepare(mediaSource);

            mSession.setActive(true);

            onPlay();

            //MediaSessionが配信する、再生中の曲の情報を設定
            mSession.setMetadata(MusicLibrary.getMetadata(getApplicationContext(), mediaId));
        }

        //再生をリクエストされたとき
        @Override
        public void onPlay() {
            //オーディオフォーカスを要求
            if (am.requestAudioFocus(afChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
                //取得できたら再生を始める
                mSession.setActive(true);
                exoPlayer.setPlayWhenReady(true);
            }
        }

        //一時停止をリクエストされたとき
        @Override
        public void onPause() {
            exoPlayer.setPlayWhenReady(false);
            //オーディオフォーカスを開放
            am.abandonAudioFocus(afChangeListener);
        }

        //停止をリクエストされたとき
        @Override
        public void onStop() {
            onPause();
            mSession.setActive(false);
            //オーディオフォーカスを開放
            am.abandonAudioFocus(afChangeListener);
        }

        //シークをリクエストされたとき
        @Override
        public void onSeekTo(long pos) {
            exoPlayer.seekTo(pos);
        }

        //次の曲をリクエストされたとき
        @Override
        public void onSkipToNext() {
            index++;
            if (index >= MusicLibrary.getMediaItems().size())//ライブラリの最後まで再生したら
                index = 0;//最初に戻す

            onPlayFromMediaId(queueItems.get(index).getDescription().getMediaId(), null);
        }

        //前の曲をリクエストされたとき
        @Override
        public void onSkipToPrevious() {
            index--;
            if (index < 0)//インデックスが0以下になったら
                index = queueItems.size() - 1;//最後の曲に移動する

            onPlayFromMediaId(queueItems.get(index).getDescription().getMediaId(), null);
        }

        //WearやAutoでキュー内のアイテムを選択された際にも呼び出される
        @Override
        public void onSkipToQueueItem(long i) {
            onPlayFromMediaId(queueItems.get((int)i).getDescription().getMediaId(), null);
        }

        //Media Button Intentが飛んできた時に呼び出される
        //オーバーライド不要(今回はログを吐くだけ)
        //MediaSessionのplaybackStateのActionフラグに応じてできる操作が変わる
        @Override
        public boolean onMediaButtonEvent(Intent mediaButtonEvent) {
            KeyEvent key = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
            Log.d(TAG, String.valueOf(key.getKeyCode()));
            return super.onMediaButtonEvent(mediaButtonEvent);
        }
    };

ここで特筆しておくべきことは2点あります。

まずはonPlayFromMediaId()の最後の処理であるmSession.setMetadata()
Media SessionにMetadataを設定すると接続済みのクライアントに曲の情報が配信されます。
MainActivityを例に取るとMediaControllerのコールバックonMetadataChanged()が呼び出されます。

次にonMediaButtonEvent()
ここはMedia Button Intentが飛んできた時に呼び出される場所です。
それには有線ヘッドフォンの物理ボタンやBluetooth接続機器からのコントロールも含まれます。

ここには他のメソッドと違って実装がすでにあるため、オーバーライドする必要は基本的にありません。
例えばヘッドフォンの物理ボタン1回押しをonPlay()、onPause()に、2回押しをonSkipNext()にマッピングするなどの処理も既に実装されています。

しかし、物理ボタンの押し方で独自の操作を追加したいなどの場合はここで処理することになります。

プレイヤーの状態をクライアントに通知する

MusicService.java
    //プレイヤーのコールバック
    private Player.EventListener eventListener = new Player.DefaultEventListener() {
        //プレイヤーのステータスが変化した時に呼ばれる
        @Override
        public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
            UpdatePlaybackState();
        }
    };

    //MediaSessionが配信する、現在のプレイヤーの状態を設定する
    //ここには再生位置の情報も含まれるので定期的に更新する
    private void UpdatePlaybackState() {
        int state = PlaybackStateCompat.STATE_NONE;
        //プレイヤーの状態からふさわしいMediaSessionのステータスを設定する
        switch (exoPlayer.getPlaybackState()) {
            case Player.STATE_IDLE:
                state = PlaybackStateCompat.STATE_NONE;
                break;
            case Player.STATE_BUFFERING:
                state = PlaybackStateCompat.STATE_BUFFERING;
                break;
            case Player.STATE_READY:
                if (exoPlayer.getPlayWhenReady())
                    state = PlaybackStateCompat.STATE_PLAYING;
                else
                    state = PlaybackStateCompat.STATE_PAUSED;
                break;
            case Player.STATE_ENDED:
                state = PlaybackStateCompat.STATE_STOPPED;
                break;
        }

        //プレイヤーの情報、現在の再生位置などを設定する
        //また、MeidaButtonIntentでできる操作を設定する
        mSession.setPlaybackState(new PlaybackStateCompat.Builder()
                .setActions(PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_STOP)
                .setState(state, exoPlayer.getCurrentPosition(), exoPlayer.getPlaybackParameters().speed)
                .build());
    }

始めのコールバックはExoPlayerのものです。名前の通りPlayerのステータスが変化した場合に呼び出されます。

UpdatePlaybackState()ではMediaSessionが配信するプレイヤーの状態を設定します。
配信する内容は、Playing、Pausedなどの状態や、再生位置、再生速度、現在受け付けられる操作です。
プレイヤーの状態を示すステータスPlaybackStateCompat.STATE_XXXXとExoPlayerのステータスは別物な為、対応するものに変換する必要があります。
因みに、再生位置が含まれているということは常に再生位置を配信し続ける必要があることを意味します。
そこで初期化(onCreate)の部分で0.5秒ごとにここを呼び出すようにしています。

通知の作成

MusicService.java
    //通知を作成、サービスをForegroundにする
    private void CreateNotification() {
        MediaControllerCompat controller = mSession.getController();
        MediaMetadataCompat mediaMetadata = controller.getMetadata();

        if (mediaMetadata == null && !mSession.isActive()) return;

        MediaDescriptionCompat description = mediaMetadata.getDescription();

        NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext());

        builder
                //現在の曲の情報を設定
                .setContentTitle(description.getTitle())
                .setContentText(description.getSubtitle())
                .setSubText(description.getDescription())
                .setLargeIcon(description.getIconBitmap())

                // 通知をクリックしたときのインテントを設定
                .setContentIntent(createContentIntent())

                // 通知がスワイプして消された際のインテントを設定
                .setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                        PlaybackStateCompat.ACTION_STOP))

                // 通知の範囲をpublicにしてロック画面に表示されるようにする
                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

                .setSmallIcon(R.drawable.exo_controls_play)
                //通知の領域に使う色を設定
                //Androidのバージョンによってスタイルが変わり、色が適用されない場合も多い
                .setColor(ContextCompat.getColor(this, R.color.colorAccent))

                // Media Styleを利用する
                .setStyle(new android.support.v4.media.app.NotificationCompat.MediaStyle()
                        .setMediaSession(mSession.getSessionToken())
                        //通知を小さくたたんだ時に表示されるコントロールのインデックスを設定
                        .setShowActionsInCompactView(1));

        // Android4.4以前は通知をスワイプで消せないので
        //キャンセルボタンを表示することで対処
        //今回はminSDKが21なので必要ない
        //.setShowCancelButton(true)
        //.setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this,
        //        PlaybackStateCompat.ACTION_STOP)));

        //通知のコントロールの設定
        builder.addAction(new NotificationCompat.Action(
                R.drawable.exo_controls_previous, "prev",
                MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                        PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)));

        //プレイヤーの状態で再生、一時停止のボタンを設定
        if (controller.getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING) {
            builder.addAction(new NotificationCompat.Action(
                    R.drawable.exo_controls_pause, "pause",
                    MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                            PlaybackStateCompat.ACTION_PAUSE)));
        } else {
            builder.addAction(new NotificationCompat.Action(
                    R.drawable.exo_controls_play, "play",
                    MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                            PlaybackStateCompat.ACTION_PLAY)));
        }


        builder.addAction(new NotificationCompat.Action(
                R.drawable.exo_controls_next, "next",
                MediaButtonReceiver.buildMediaButtonPendingIntent(this,
                        PlaybackStateCompat.ACTION_SKIP_TO_NEXT)));

        startForeground(1, builder.build());

        //再生中以外ではスワイプで通知を消せるようにする
        if (controller.getPlaybackState().getState() != PlaybackStateCompat.STATE_PLAYING)
            stopForeground(false);
    }

再生コントロール付きの通知を作成するにはsetStyleでMediaStyleを指定して、addActionでMedia Button Intentを設定して上げる必要があります。

Audio Focusを扱う

MusicService.java
    //オーディオフォーカスのコールバック
    AudioManager.OnAudioFocusChangeListener afChangeListener =
            new AudioManager.OnAudioFocusChangeListener() {
                public void onAudioFocusChange(int focusChange) {
                    //フォーカスを完全に失ったら
                    if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
                        //止める
                        mSession.getController().getTransportControls().pause();
                    } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {//一時的なフォーカスロスト
                        //止める
                        mSession.getController().getTransportControls().pause();
                    } else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) {//通知音とかによるフォーカスロスト(ボリュームを下げて再生し続けるべき)
                        //本来なら音量を一時的に下げるべきだが何もしない
                    } else if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {//フォーカスを再度得た場合
                        //再生
                        mSession.getController().getTransportControls().play();
                    }
                }
            };

他のアプリが音を流し始め、このアプリのAudio Focusが失われた場合、再生を停止するようにする仕組みを実装しています。

感想

Android5.0から実装されたAPIだけあってとても良くできていると感じました。

Androidの音楽プレイヤーは既にたくさんリリースされているので、特に純粋な音楽プレイヤーを自分で作る機会はほとんどないと思いますが、ネットラジオなど、バックグラウンドで音楽を再生する機会があれば役に立つかもしれません。

1つの記事でAndroidのMedia APIを解説してみました。
かなり長くなり自分でも驚いています。
こういった場合、分割したほうが読みやすいのでしょうか?

とにかく、最後までご覧下さりありがとうございました。

Googleの公式サンプル

Android MediaBrowserService Sample
 これを簡略化して今回のサンプルを作成した。
Media Controller Test
 Media BrowserとMedia Controllerを実装しており、他の音楽アプリの制御ができるサンプル。
Universal Android Music Player Sample
 かなりきちんとした音楽プレイヤーの実装。Media Browser Serviceのブラウジング機能をきちんと使いこなしている。