見出し画像

【Tech Blog】Android向け音楽プレイヤーの実装について

先日 Unity で制作中の Android アプリで、オーディオプレイヤーのようなシステムを実装する機会がありました。

バックグラウンドで音楽を流したり、通知欄やイヤホンで再生 / 停止を制御したりという内容なのですが、仕組みが理解できず、実装を進める中で行き詰まることもありました。

これらの経験から、今回はオーディオプレイヤーの仕組みのまとめや、サンプルコードをご紹介します。



■ 今回サンプルコードで実装できるもの

今回は以下の機能を実装していきます。

-Serviceの開始と音楽の再生:

Service が開始時、コード内で指定した音声ファイルを再生
また Service 開始に必要なため、オーディオ用の通知も表示

 

-オーディオ通知欄にオーディオの再生 / 停止ボタンを表示:

オーディオの再生/停止制御ボタンを Android の通知エリアに表示

 

-アプリUI / 通知 / イヤホンからの操作:

アプリ UI やオーディオ通知欄、イヤホンの操作で、曲の再生 / 停止の制御

 
なお、今回は Android8.0 以上を対象としています。
( Android8.0 未満では、通知チャンネル追加やバックグラウンド再生制御周りの対応が異なります。)

※参考:
 ・バックグラウンド実行制限
 https://developer.android.com/about/versions/oreo/background?hl=ja

 ・通知チャネルを作成して管理する
 https://developer.android.com/training/notify-user/channels?hl=ja


■ Android で音楽プレイヤーアプリを開発するために必要な要素

コードの説明に入る前に、音楽プレイヤーを実装するために必要な要素について見ていきます。

Android の音楽プレイヤーは以下のような要素で構成されています。

MediaBrowserServiceCompat
MediaSession への接続やプレーヤーの制御などをサポートするサービスを作成するためのクラスです。

MediaBrowserCompat
MediaBrowserServiceCompat との接続や MediaController の作成を行うクライアントです。

MediaSession
音楽の再生状態を管理するために使用されるセッションです。

MediaController
MediaSession から音楽を制御するために使用されるコントローラーです。

ExoPlayer
MediaPlayer
音楽ファイルを再生する際に使用します。(最近だと ExoPlayer が使われることが多いようです。)

Notification
通知を作成し表示する際に使用します。


■ サンプルコード

-プロジェクト作成

まずは適当なプロジェクトを用意します。
今回 TargetSDK は 32、minSDK は 26 としました。
実装したいプロジェクトの要件によって変更してください。

build.gradle
```
    defaultConfig {
        applicationId "com.example.audioplayerexample"
        minSdk 26
        targetSdk 32
        versionCode 1
        versionName "1.0"
…
    }
```

今回のサンプルでは、Empty Activity にボタンを二つ追加した UI に機能実装をしていきます。


-ライブラリ追加

build.gradle には、以下のライブラリを追加します。

```
   implementation 'com.google.android.exoplayer:exoplayer:2.18.0'
   implementation 'com.google.android.exoplayer:extension-mediasession:2.18.0'
```

com.google.android.exoplayer:extension-mediasession:2.18.0:
メディアセッション、メディアブラウザ、および音声フォーカスの管理などを行うライブラリ

com.google.android.exoplayer:exoplayer:2.18.0:
Google が開発した Android 向けのメディア再生ライブラリ


※参考:https://developer.android.com/guide/topics/media/exoplayer?hl=ja


-AndroidManifest.xml に設定を追記

次に、Service の利用や、通知欄のボタンやイヤホン操作で発行されたイベントを受け取れるように、AndroidManifest.xml にservice や receiver を追加していきます。

また、実装に必要なパーミッションについても追加します。

今回は Service のクラス名を AudioPlayerExampleService という名前で作成しています。

AndroidManifest.xml
```
…
        <service
            android:name=".AudioPlayerExampleService"
            android:exported="true"
            android:enabled="true"
            >
            <intent-filter>
                <action android:name="android.media.browse.MediaBrowserService" />
            </intent-filter>
        </service>

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

    </application>

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

</manifest>
```

androidx.media.session.MediaButtonReceiver:
イヤホンや通知欄での再生/停止イベントを受け取るためのレシーバーです。 

※参考:https://developer.android.com/reference/androidx/media/session/MediaButtonReceiver

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />:
Foreground Service を利用するためのものです。
Android8.0 以上では、Background Service だと、アプリがバックグラウンドになって数分後には Service が停止してしまいます。
そのため Foreground Service として Service を起動する必要があります。

※参考 : https://developer.android.com/guide/components/foreground-services

 

-service としてマニフェストに指定したクラスを Service を継承したクラスとして実装

service としてマニフェストに指定したクラスが Service を継承したクラスとして実装されていない場合、
“’.AudioPlayerExampleService’ must extend android.app.Service” のようなコンパイルエラーが出力されます。

指定したクラスに MediaBrowserServiceCompat を実装します。


AudioPlayerExampleService.java
```
public class AudioPlayerExampleService extends MediaBrowserServiceCompat{
    @Nullable
    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
        return new BrowserRoot("root", null);
    }

   @Override
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
        result.sendResult(null);
    }
```


-音声ファイルの準備

コードの実装に入る前に、再生する音声ファイルの準備を行います。
AndroidStudio 画面左側のプロジェクト構造の res フォルダの上で右クリックして、New → Folder → Raw Resources Folder を選択し、raw フォルダを作成します。
追加された raw に、再生したい音声ファイルを配置してください。

また、今回のサンプルコードでは音声ファイルを bgm という名前で定義して使用しています。
必要に応じて名前を変更してください。

 

-Serviceの開始と音楽の再生

まずは Service の開始と音楽の再生機能を実装します。

MainActivity にコードを追加します。
主に Service の開始を行います。

MainActivity.java
```
public class MainActivity extends AppCompatActivity {

    private final String TAG = "MainActivity";
    private MediaBrowserCompat mediaBrowser;
    private MediaControllerCompat mediaController;
    private Intent serviceIntent;

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

        serviceIntent = new Intent(getApplicationContext(),AudioPlayerExampleService.class);

        startForegroundService(serviceIntent);
        connectMediaBrowser();
    }

    // MediaBrowserServiceとの接続
    private void connectMediaBrowser() {
        //MediaBrowserを初期化
        mediaBrowser = new MediaBrowserCompat(getApplicationContext(), new ComponentName(getApplicationContext(),AudioPlayerExampleService.class), connectionCallback, null);
        //接続(サービスをバインド)
        mediaBrowser.connect();
        Log.d(TAG," connectMediaBrowser()");
    }

    //接続時に呼び出されるコールバック
    private MediaBrowserCompat.ConnectionCallback connectionCallback = new MediaBrowserCompat.ConnectionCallback() {
        @Override
        public void onConnected() {
            Log.d(TAG," onConnected Call");

            //接続が完了するとSessionTokenが取得できるので
            //それを利用してMediaControllerを作成
            if(mediaBrowser == null) return;
            mediaController = new MediaControllerCompat(getApplicationContext(), mediaBrowser.getSessionToken());
            // 以下を追加
            MediaControllerCompat.setMediaController(MainActivity.this, mediaController);
            //サービスから送られてくるプレイヤーの状態や曲の情報が変更された際のコールバックを設定
            mediaController.registerCallback(controllerCallback);
        }
    };

    //MediaControllerのコールバック
    private MediaControllerCompat.Callback controllerCallback = new MediaControllerCompat.Callback() {
        //再生中の曲の情報が変更された際に呼び出される
        @Override
        public void onMetadataChanged(MediaMetadataCompat metadata) {
        }

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

続いて、AudioPlayerExampleService に機能を実装していきます。
主に ExoPlayer での再生、Notification での通知欄表示を行います。

AudioPlayerExampleService.java
```
public class AudioPlayerExampleService extends MediaBrowserServiceCompat{

    private final String TAG = "AudioPlayerExampleService";

    private MediaSessionCompat mediaSession = null;
    private ExoPlayer exoPlayer = null;

    private NotificationManager notificationManager = null;
    private NotificationCompat.Builder notificationBuilder = null;

    public Notification showNotification()
    {
        notificationBuilder =
                new NotificationCompat.Builder(getApplicationContext(), "CHANNEL_ID");
        notificationBuilder
                .setStyle(new androidx.media.app.NotificationCompat.MediaStyle().setMediaSession(mediaSession.getSessionToken()).setShowActionsInCompactView(0))
                .setColor(ContextCompat.getColor(getApplicationContext(), android.R.color.background_light))
                .setSmallIcon(android.R.drawable.ic_media_play)
                .setContentTitle("曲のタイトル")
                .setContentText("アーティストの名前");

        Notification notify = notificationBuilder.build();

        // ForegroundServiceを起動するためには、通知を表示する必要がある
        startForeground(1, notify);
        return notify;
    }

    public void createNotifyChannel()
    {
        int importance = NotificationManager.IMPORTANCE_DEFAULT;
        NotificationChannel channel = new NotificationChannel("CHANNEL_ID", "サンプルアプリ", importance);
        channel.setDescription("説明・説明 ここに通知の説明を書くことができます");
        notificationManager = (NotificationManager)getApplicationContext().getSystemService(getApplicationContext().NOTIFICATION_SERVICE);
        notificationManager.createNotificationChannel(channel);
    }

    public void audioPlay()
    {
        mediaSession.setActive(true);
        exoPlayer.setPlayWhenReady(true);
        Log.d(TAG, "onPlay");
    }

    public void playSetUp(Uri mediaURI)
    {
        ExoPlayer player = new ExoPlayer.Builder(getApplicationContext()).build();
        DataSource.Factory sourceFactory = new DefaultDataSource.Factory(getApplicationContext());
        ProgressiveMediaSource mediaSource= new ProgressiveMediaSource.Factory(sourceFactory).createMediaSource(MediaItem.fromUri(mediaURI));
        player.setMediaSource(mediaSource);
        player.prepare();
        exoPlayer = player;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // メディアセッションを作成
        mediaSession = new MediaSessionCompat(getApplicationContext(),TAG);
        // 通知チャンネルを作成
        createNotifyChannel();
        // ExoPlayerを作成し、再生準備
        playSetUp(RawResourceDataSource.buildRawResourceUri(R.raw.bgm));
        audioPlay();

        // MediaSessionとExoPlayerを接続
        MediaSessionConnector connector = new MediaSessionConnector(mediaSession);
        connector.setPlayer(exoPlayer);

        // 通知を表示
        showNotification();
        return START_NOT_STICKY;
    }

    @Override
    public void onDestroy(){
        super.onDestroy();
        stopSelf();
    }

    @Override
    public void onTaskRemoved(Intent rootIntent)
    {
        super.onTaskRemoved(rootIntent);
        exoPlayer.stop();
        stopForeground(true);

    }

    @Nullable
    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
        return new BrowserRoot("root", null);
    }

    @Override
    public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
        result.sendResult(null);
    }
}
```

ここまで実装できたら、一度実行してみましょう。

音楽が再生され、通知が表示されていることが確認できます。


-オーディオ通知欄にオーディオの再生 / 停止ボタンを表示

続いて、AudioPlayerExampleService の showNotification() に再生 / 停止アクションや UI 表示切り替えの処理を追記し、通知欄にオーディオの再生ボタンを表示します。

AudioPlayerExampleService.java
```diff
    public Notification showNotification()
    {
        notificationBuilder =
                new NotificationCompat.Builder(getApplicationContext(), "CHANNEL_ID");
        notificationBuilder
                .setStyle(new androidx.media.app.NotificationCompat.MediaStyle().setMediaSession(mediaSession.getSessionToken()).setShowActionsInCompactView(0))
                .setColor(ContextCompat.getColor(getApplicationContext(), android.R.color.background_light))
                .setSmallIcon(android.R.drawable.ic_media_play)
                .setContentTitle("曲のタイトル")
                .setContentText("アーティストの名前");

+        // アクションの作成
+        NotificationCompat.Action startAction =
+                new NotificationCompat.Action.Builder(android.R.drawable.ic_media_play, "play", MediaButtonReceiver.buildMediaButtonPendingIntent(getApplicationContext(), PlaybackStateCompat.ACTION_PLAY))
+                        .build();
+        // アクションの作成
+        NotificationCompat.Action pauseAction =
+                new NotificationCompat.Action.Builder(android.R.drawable.ic_media_pause, "pause", MediaButtonReceiver.buildMediaButtonPendingIntent(getApplicationContext(), PlaybackStateCompat.ACTION_PAUSE))
+                        .build();

+      // 現状のステータスによって通知の表示ボタンを変更
+      if(!exoPlayer.getPlayWhenReady()) {
+            notificationBuilder.addAction(startAction);
+        }
+        else {
+            notificationBuilder.addAction(pauseAction);
+        }

        Notification notify = notificationBuilder.build();

        // ForegroundServiceを起動するためには、通知を表示する必要がある
        startForeground(1, notify);
        return notify;
    }
```

赤枠部分が表示されるようになっていることが確認できます。

 

-アプリUI/通知/イヤホンからの操作

最後に、アプリ UI や通知、イヤホンからの再生 / 停止イベントを受け取れるようにします。
まずは MainActivity にボタンイベントを追記します。

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

        serviceIntent = new Intent(getApplicationContext(),AudioPlayerExampleService.class);

        startForegroundService(serviceIntent);
        connectMediaBrowser();

+        Button playButton = findViewById(R.id.play);
+        playButton.setOnClickListener(new View.OnClickListener(){
+            @Override
+            public void onClick(View view){
+                play();
+            }
+        });

+        Button pauseButton = findViewById(R.id.pause);
+        pauseButton.setOnClickListener(new View.OnClickListener(){
+            @Override
+            public void onClick(View view){
+                pause();
+            }
+        });
    }

+    private void play() {
+        MediaControllerCompat.getMediaController(MainActivity.this).getTransportControls().play();
+    }

+    private void pause(){
+      MediaControllerCompat.getMediaController(MainActivity.this).getTransportControls().pause();
+    }
```

続いて、AudioPlayerExampleService に再生 / 停止イベントを受け取れるコールバックを追記します。
また、通知欄のボタン表示が変更するようにもしておきます。

AudioPlayerExampleService.java
```diff
    public void audioPlay()
    {
        mediaSession.setActive(true);
        exoPlayer.setPlayWhenReady(true);
        Log.d(TAG, "onPlay");
+       showNotification();
    }

    public void audioPause()
    {
        exoPlayer.setPlayWhenReady(false);
        mediaSession.setActive(false);
        Log.d(TAG,"onPause");
+       showNotification();
    }

+    public void createMediaSessionCallback()
+    {
+        MediaSessionCompat.Callback sessionCallback = new MediaSessionCompat.Callback()
+        {
+            @Override
+            public void onPlay() {
+                audioPlay();
+            }
+            @Override
+            public void onPause() {
+                audioPause();
+            }
+        };
+        //クライアントからの操作に応じるコールバックを設定
+        mediaSession.setCallback(sessionCallback);
+        //MediaBrowserServiceにSessionTokenを設定
+        this.setSessionToken(mediaSession.getSessionToken());
+    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // メディアセッションを作成
        mediaSession = new MediaSessionCompat(getApplicationContext(),TAG);
        // 通知チャンネルを作成
        createNotifyChannel();
        // ExoPlayerを作成し、再生準備
        playSetUp(RawResourceDataSource.buildRawResourceUri(R.raw.bgm));
        audioPlay();

        // MediaSessionとExoPlayerを接続
        MediaSessionConnector connector = new MediaSessionConnector(mediaSession);
        connector.setPlayer(exoPlayer);

+       // メディアボタンからの受け取ったイベントのコールバックを設定
+       createMediaSessionCallback();
        // 通知を表示
        showNotification();
        return START_NOT_STICKY;
    }
```


■ 動作確認

コードが実装できたら実際に実行してみます。

アプリが起動したら PLAY ボタンを押すことで音声の再生されること、PAUSE ボタンで音声が停止されることを確認してみてください。

また、通知欄を開いた時に、AudioPlayerExample アプリの通知が表示されていることを確認してください。
starticon / pauseicon をタップすることで、再生 / 停止されると思います。

ここまでできたら今回は完了となります。


■ まとめ

いかがでしたでしょうか。

今回のサンプルコードでは、音楽アプリにとって最低限の機能を実装しています。
実際の音楽アプリを作ろうと思うと、まだオーディオフォーカスの制御であったり、イヤホンの接続が外れた際の処理なども考慮していかなければなりません。

また、この辺りは Android のバージョンによって実装方法が変わってくるため、少し取っ掛かりにくく、参考となる記事も少なく、新しい情報をキャッチアップするのも難しく感じました。

ただ、マスターすれば音声を扱うアプリは色々と実装の幅が広がるのではないでしょうか。
ぜひオリジナルのオーディオプレイヤーアプリの実装を試してみてください。


執筆:齋藤 夕貴|株式会社セガ エックスディー

セガ エックスディーで、Unityをメインに様々なシステムの開発を行なっています。最近はインフラやweb周りも勉強中です。xR系コンテンツ開発が好きです。


■ SEGA XD HP:https://segaxd.co.jp/
■ SEGA XD 公式 Twitter:https://twitter.com/SEGAXD_PR
■ SEGA XD 公式 Facebook:https://www.facebook.com/segaxd.fb/
■ CX School 公式 Youtube:https://www.youtube.com/@cxschool