Universal Android Music Player(UAMP)のカスタマイズ （音源の変更　1/2）

#はじめに
Android用アプリのプログラミングを学ぶため、Googleが公開しているサンプルコード　**Universal Android Music Player(UAMP)**をカスタマイズしてみます。
まずは、端末（アプリを実行するスマートフォンなど）内の好きな音楽ファイルを再生するように変更します。


#環境
####PC
MacBook Pro 16
2.3 GHz 8コアIntel Core i9
16 GB 2667 MHz DDR4
macOS Big Sur ver.11.5.2

####開発用SW
Android Studio 4.2.1

####Target Device (Virtual Device)
Category: Phone
Name: Pixcel 2
Resolution: 1080x1920 420bpi
API Level: 28
Android: 9.0
CPU: x86

#Universal Android Music Player (UAMP)
###UAMP概要
[UAMP](https://github.com/android/uamp "github android/uamp")は、Googleが公開しているオーディオプレイヤーのサンプルコードであり、Googleがオーディオアプリのアーキテクチャとして推奨しているクライアント/サーバー型のアーキテクチャで設計されており、メディアプレイヤーのエンジンとして**ExoPlayer２**を使用している。
UAMPのコードは、[GitHub](https://github.com/android/uamp "github android/uamp")のリポジトリから取得可能。
また、UAMPの設計については、GitHubのリポジトリに含まれれいる["Full Gide to UAMP"](https://github.com/android/uamp/blob/main/docs/FullGuide.md)で説明されている。

###UAMPの動作
UAMPは、リモートサーバーから楽曲のカタログを取得して、メニューリストに表示する。メニュー上でユーザーが選択した楽曲をリモートサーバーにリクエストし、ストリーミング再生する。接続するリモートサーバーは特に設定する必要はなく（というか、設定はできず）、あらかじめ決められているサーバーに接続する。
楽曲の再生が完了すると、自動的に同じアルバムの次の楽曲の再生を開始する。

アプリ起動直後に表示されるルートメニューから、再生画面までの画面遷移は下記のとおり。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679257/21abffa5-ba44-76ba-95b5-3fa91fe81a07.png)
ユーザーがメニューリストの項目を選択（タップ）すると下位のメニューに遷移し、楽曲メニューで曲を選択すると再生を開始して再生画面に遷移する。上位メニューへの遷移は、ナビゲーションバーの戻るボタンを使用する。

再生画面には、再生/停止ボタンが表示され、再生の一時停止、および再開の操作が可能。停止状態で再生ボタンをタップすると停止前の位置から再生が再開される。
再生画面でナビゲーションバーの戻るボタンをタップすると、再生画面に遷移する前の楽曲メニュー（リコメンデッド楽曲メニューまたはアルバム楽曲メニュー）に戻る。

再生中にアプリを閉じてもバックグラウンドで再生は継続し、アプリを再起動すると、閉じる前の画面から再開する。
バックグラウンド再生中も通知のドロワーからプレイヤーの操作ができる。通知ドロワーからの操作は、再生/停止の他、楽曲の先頭および最後へのシークが可能。
![スクリーンショット 2021-08-15 20.13.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679257/2673ef3a-d8fb-be80-8753-6b8cb7344294.png)


###オリジナルコード確認
オリジナルのコードでの楽曲カタログのダウンロードからプレイヤーへの設定までの処理の流れを確認する。
処理の中心は、MusicSource.kt。

class MusicServiceのメンバーの宣言で、取得する楽曲のカタログのURIを指定している。

onCreate()が呼び出されてMusicSourceのアクティビティーが生成されると、mediaSourceというJsonSourceのインスタンスを生成し、リモートサーバーから楽曲カタログを取得して、プレイヤーで使用可能な形態の楽曲情報のリストに変換する。また、プレイヤーの操作要求に対するコールバックを設定し、メディア再生のが可能な状態にする。

楽曲メニュー画面で楽曲が選択されると、onPrepareFromMediaId()が呼び出され、この中で選択された楽曲を含むアルバム内の楽曲情報のリストと選択された楽曲のIDをプレイヤーに渡し、選択された楽曲からアルバムの再生を開始する。

```kotlin:MusicService.kt
open class MusicService : MediaBrowserServiceCompat() {

    //中略

    //126行目あたり
    //楽曲カタログのURIを指定。
    private val remoteJsonSource: Uri =
        Uri.parse("https://storage.googleapis.com/uamp/catalog.json")

    //中略

    //170行目あたり
    //アクティビティーを生成
    override fun onCreate() {

        //中略

        //210行目あたり
        //リモートサーバーから楽曲カタログを取得する。
        //JsonSourceは、指定されたURIから楽曲カタログを取得し、プレイヤーで使用可能な形態で保持するクラス。
        //楽曲カタログの取得は、コルーチンで実施する。
        mediaSource = JsonSource(source = remoteJsonSource)
        serviceScope.lauhnch {
            mediaSource.load()
        }

        //中略

        //218行目あたり
        //MediaSessionConnectorのインスタンスを生成し、メディアの再生を準備する。また、メディアセッションの
        //キューナビゲータをセットする。
        //
        //MediaSessionConnectorのインスタンスの生成
        mediaSessionConnector = MediaSessionConnector(mediaSession)
        //プレイヤーへの操作の要求に対するコールバックを設定し、メディアの再生を準備
        mediaSessionConnector.setPlaybackPreparer(UampPlaybackPreparer())
        //キューナビゲータの設定
        mediaSessionConnector.setQueueNavigator(UampQueueNavigator(mediaSession))

        //中略

    //381行目あたり
    //プレイリストと再生する楽曲のIDをプレイヤー（ExoPlayer2）にくべて、再生を開始する関数
    private fun preparePlaylist(
        metadataList: List<MediaMetadataCompat>,
        itemToPlay: MediaMetadataCompat?,
        playWhenReady: Boolean,
        playbackStartPositionMs: Long
    ) {

        //中略

        //プレイヤーがExoPlayerであれば、再生する楽曲のリストをプレイヤーに設定し、指定された楽曲から再生を開始する。
        //再生する楽曲のリストと再生を開始する楽曲は、引数としてこの関数の呼び出し元から渡される。
        //このアプリの再生はアルバム単位での再生であり、楽曲のリストは、アルバム内の楽曲のリスト。
        //   (プレイヤーがExoPlayerではなくキャストプレイヤーであれば異なる処理）
        if (currentPlayer == exoPlayer) {
            //引数として与えられた再生する楽曲のリスト（楽曲カタログから抽出した楽曲情報のリスト）を、
            //プレイヤーで再生可能な形態の情報のリストへ変換。
            val mediaSource = metadataList.toMediaSource(dataSourceFactory)
            //プレイヤーに再生する楽曲のリストを設定。
            exoPlayer.prepare(mediaSource)
            //設定した楽曲のリストの中の、指定した楽曲から再生を開始することをプレイヤーに要求。
            exoPlayer.seekTo(initialWindowIndex, playbackStartPositionMs)
        } else /* currentPlayer == castPlayer */ {

        //中略

    }

    //476行目あたり
    //MediaSessionConnector.PlaybackPreparerは、ExoPlayerを使用したメディア再生の準備と、再生の実行のためのインターフェース機能を担う。
    private inner class UampPlaybackPreparer : MediaSessionConnector.PlaybackPreparer {

        //中略

        //指定されたmediaIdの楽曲の再生を準備する。
        //楽曲メニュー画面での楽曲が選択による再生要求に対するコールバック。
        override fun onPrepareFromMediaId(
            mediaId: String,
            playWhenReady: Boolean,
            extras: Bundle?
        ) {
            mediaSource.whenReady {
                //選択された楽曲の情報を楽曲カタログから抽出。
                val itemToPlay: MediaMetadataCompat? = mediaSource.find { item ->
                    item.id == mediaId
                }
                if (itemToPlay == null) {
                   //選択された楽曲の情報が楽曲カタログの中に見つからなければ、エラー処理。

　                   //中略

                } else {
                    //選択された楽曲の情報が楽曲カタログの中に見つかれば、その楽曲を含むアルバム内の楽曲の情報のリストと再生を開始する楽曲を指定して、再生を開始。

　                   //中略

                    //選択楽曲を含むアルバム内の楽曲の情報のリストと再生を開始する楽曲を指定して、再生を開始。
                    preparePlaylist(
                        //選択された楽曲を含むアルバム内の楽曲のリストを生成。
                        buildPlaylist(itemToPlay),
                        itemToPlay,
                        playWhenReady,
                        playbackStartPositionMs
                    )
                }
            }
        }

        //中略

        //楽曲カタログ（mediaSource）から、指定された楽曲と同じアルバムの楽曲情報を抽出してリストを生成する。
        private fun buildPlaylist(item: MediaMetadataCompat): List<MediaMetadataCompat> =
            mediaSource.filter { it.album == item.album }.sortedBy { it.trackNumber }
    }

    //中略

}
```


ダウンロードする楽曲カタログ（catalog.json)の内容は下記のとおり。各楽曲のid、タイトル、アルバム名、アーティスト、ジャンル、音源のURI、アルバムイメージのURI、トラック番号、アルバムの総トラック数、演奏時間、（おそらく）音源提供元サイトURIが記載されている。

```json:catalog.json
{
    "music": [
    {
        "id": "wake_up_01",
        "title": "Intro - The Way Of Waking Up (feat. Alan Watts)",
        "album": "Wake Up",
        "artist": "The Kyoto Connection",
        "genre": "Electronic",
        "source": "https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/01_-_Intro_-_The_Way_Of_Waking_Up_feat_Alan_Watts.mp3",
        "image": "https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/art.jpg",
        "trackNumber": 1,
        "totalTrackCount": 13,
        "duration": 90,
        "site": "http://freemusicarchive.org/music/The_Kyoto_Connection/Wake_Up_1957/"
    },

    （中略）

    ]
}
```

JasonSource.load()では、指定されたURIから楽曲カタログを取得し、プレイヤーで使用可能な形態（MediaMetadataCompat)に変換する。

```JsonSource.kt
class JsonSource(private val source: Uri) : AbstractMusicSource() {

    private var catalog: List<MediaMetadataCompat> = emptyList()

    //中略

//65行目あたり
//引数で指定されたURIから楽曲のカタログを取得して、ExoPlayerで利用可能な形態の楽曲情報のリストを作成する関数
    override suspend fun load() {
        //楽曲カタログの取得と変換が正常に完了したとき（関数updateCatalog()の戻り値がnullではないとき）は、取得した情報をJsonSourceに適用する。
        //また、JsonSourceの状態を示す変数も更新する。
        updateCatalog(source)?.let { updatedCatalog ->
            //楽曲カタログの取得に成功したときの処理。
            catalog = updatedCatalog
            state = STATE_INITIALIZED
        } ?: run {
            //楽曲カタログの取得に失敗したときの処理。
            catalog = emptyList()
            state = STATE_ERROR
        }
    }

    //楽曲カタログを取得して、ExoPlayerで利用可能な形態に変換する関数。
    private suspend fun updateCatalog(catalogUri: Uri): List<MediaMetadataCompat>? {
        //IO処理用に予約されたスレッドへコンテキストを切り替えて処理を実行し、結果を返す。
        return withContext(Dispatchers.IO) {
            val musicCat = try {
                //指定されたURIから楽曲カタログを取得する。
                downloadJson(catalogUri)
            } catch (ioException: IOException) {
                //楽曲カタログの取得に失敗したときはnullを返す。
                return@withContext null
            }

            //相対パスから絶対パスへの変換用に、楽曲カタログ取得用のURIから最後のセグメントを除いた文字列を抽出する。
            //   (https://storage.googleapis.com/uamp/)
            val baseUri = catalogUri.toString().removeSuffix(catalogUri.lastPathSegment ?: "")
            //取得した楽曲カタログ内の楽曲の情報の各項目をExoPlayerで利用可能な楽曲情報の項目にマッピングして、楽曲情報のリストを作成する。
            val mediaMetadataCompats = musicCat.music.map { song ->
                //取得した楽曲カタログ内のURIが相対パスであった場合、絶対パスに変換する。
                catalogUri.scheme?.let { scheme ->
                    if (!song.source.startsWith(scheme)) {
                        song.source = baseUri + song.source
                    }
                    if (!song.image.startsWith(scheme)) {
                        song.image = baseUri + song.image
                    }
                }
                //アルバム画像のURIのスキームを、httpsからcontentに変換する。また、パス部分は、RFC2396の予約文字をパーセントエンコーディングする。
                //例
                //  変換前　　　https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/art.jpg
                //  変換後　　content://com.example.android.uamp/uamp%3AThe_Kyoto_Connection_-_Wake_Up%3Aart.jpg
                val imageUri = AlbumArtContentProvider.mapUri(Uri.parse(song.image))

                //取得した楽曲カタログ内の楽曲の情報の各項目をExoPlayerで利用可能な楽曲情報の項目にマッピングする。
                MediaMetadataCompat.Builder()
                    .from(song)
                    .apply {
                        displayIconUri = imageUri.toString() // Used by ExoPlayer and Notification
                        albumArtUri = imageUri.toString()
                    }
                    .build()
            }.toList()
            // リストの各要素に対して、descriptionのextrasにbundleに保存されている値をdescriptionに付加する。
            mediaMetadataCompats.forEach { it.description.extras?.putAll(it.bundle) }
            mediaMetadataCompats
        }
    }

    //引数で指定されたURIから楽曲のカタログを取得する関数
    private fun downloadJson(catalogUri: Uri): JsonCatalog {
        val catalogConn = URL(catalogUri.toString())
        val reader = BufferedReader(InputStreamReader(catalogConn.openStream()))
        return Gson().fromJson(reader, JsonCatalog::class.java)
    }
}

//132行目あたり
//取得した楽曲カタログ内の楽曲の情報の項目を、ExoPlayerで利用する楽曲情報の各項目にマッピングする関数。
fun MediaMetadataCompat.Builder.from(jsonMusic: JsonMusic): MediaMetadataCompat.Builder {
    //取得する楽曲情報内の演奏時間（duration）は秒で与えられるが、このアプリでの処理はミリ秒を使用するため、ミリ秒に変換する。
    val durationMs = TimeUnit.SECONDS.toMillis(jsonMusic.duration)

    id = jsonMusic.id
    title = jsonMusic.title
    artist = jsonMusic.artist
    album = jsonMusic.album
    duration = durationMs
    genre = jsonMusic.genre
    mediaUri = jsonMusic.source
    albumArtUri = jsonMusic.image
    trackNumber = jsonMusic.trackNumber
    trackCount = jsonMusic.totalTrackCount
    flag = MediaItem.FLAG_PLAYABLE

    //表示用のプロパティも設定する。
    displayTitle = jsonMusic.title
    displaySubtitle = jsonMusic.artist
    displayDescription = jsonMusic.album
    displayIconUri = jsonMusic.image

    //MediaMetadataCompatの結果で、"extras"のbundleを生成させらるため、ダウンロードステタスを付加する。
    //これは、更新中にメディアセッションに正しいメタデータを渡すのに必要。
    downloadStatus = STATUS_NOT_DOWNLOADED

    return this
}

//以下省略
```

#カスタマイズ方法
端末内の音楽メディアファイルの情報からExoPlayerで利用可能な形態の楽曲情報のリスト（AbstractMusicSource()）を生成するクラスを作成して、JsonSource()と置き換える。
端末内の音楽メディアファイルの情報の収集は、**MediaStore**を利用する。

Androidには、ビデオやオーディオなどのメディアファイルの情報を色々なアプリで共有できるように管理するMediaProviderという仕組み（モジュール）があり、MediaStoreはMediaProviderが管理する情報にアクセスするためにAPI群。
　
　
　
　




長くなってしまったので、ここで一旦切ります m(_ _)m
実装については、改めて投稿いたします。


#参考
[Androidデベロッパー 音声と動画　](https://developer.android.com/guide/topics/media)
[イマドキなAndroid音楽プレーヤーの作り方](https://qiita.com/siy1121/items/f01167186a6677c22435)
[ゼロから学ぶメディアプレイヤーの実装](https://dev.classmethod.jp/articles/how-to-android-media-player/)
[ExoPlayerで音声のみ簡単に再生させる方法](https://qiita.com/sudo5in5k/items/975c40ec3dbdb38259da)

#関連する投稿
[Universal Android Music Player(UAMP)のカスタマイズ （音源の変更　2/2）](https://qiita.com/Jin_Kubota/items/83ea0ec609f4ebe14dc0)
[Universal Android Music Player(UAMP)のカスタマイズ （メニューの変更　1）](https://qiita.com/Jin_Kubota/items/382f41f42247219fbe5c)
[Universal Android Music Player(UAMP)のカスタマイズ （メニューの変更　2）](https://qiita.com/Jin_Kubota/items/7a7ba6de18eb6e1db8a7)

#おわりに
プログラミング初心者、AndroidアプリのコードをいじるのもKotlinを使うのも初めてなので、誤りが多々あると思います。アドバイス、励ましのコメントなどいただけると嬉しいです。