#はじめに
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
#コードの確認
###ルートメニュー表示
アプリの起動からルートメニューを表示するまでの大体の流れは下図のとおり。
(図が細かくなってしまいました。拡大してご確認ください。)
コードの関連する部分は下記。
なお、下記のコードは、音源を端末内のローカルファイルに変更済みのもの。
class MainActivity : AppCompatActivity() {
//初期化
private val viewModel by viewModels<MainActivityViewModel> {
//下記は、変数viewModelが初めて使用されるときに実行される。
InjectorUtils.provideMainActivityViewModel(this)
}
private var castContext: CastContext? = null
//アクティビティーの生成
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//ストレージアクセス権限がなければ権限の付与を求めるダイアログを表示するため、オリジナルコードの
//onCreate()内の処理を削除し、下記を追加した。
if (!haveStoragePermission()) {
//ストレージアクセス権限がなければ、ユーザに権限の付与を求めるダイアログを表示。
requestPermission()
}else{
//ストレージアクセス権があれば、オリジナルコードのonCreate()内の処理を実施し、アクティビティー
//を生成する。
//オリジナルコードのonCreate()内の処理を、関数activityCreationProcess()とした。
activityCreationProcess()
}
}
//途中省略
//mediaIdで指定されたTagのFragmentの存在を確認し、なければ生成して表示する。
private fun navigateToMediaItem(mediaId: String) {
//mediaIdで指定されたTagのFragmentを探す。
var fragment: MediaItemFragment? = getBrowseFragment(mediaId)
//mediaIdで指定されたTagのFragmentが存在しなければ生成する。
if (fragment == null) {
//次の画面のFragmentインスタンスを生成する。
fragment = MediaItemFragment.newInstance(mediaId)
//Fragmentの変更を要求する。
viewModel.showFragment(fragment, !isRootId(mediaId), mediaId)
}
}
//途中省略
//mediaIdで指定されたTagのFragmentを探し、結果をMediaItemFragmentにcastして返す関数。
private fun getBrowseFragment(mediaId: String): MediaItemFragment? {
return supportFragmentManager.findFragmentByTag(mediaId) as MediaItemFragment?
}
//途中省略
//オリジナルコードのonCreate()で実施していた処理を行うために作成した関数。
//アクティビティーを生成する。
private fun activityCreationProcess():Unit{
//途中省略
//Fragment変更指示(MainActivityViewModelのnavigateToFragment)を監視。
//変化があればFragmentを変更する。
//変数 viewModelの宣言で初期化のために呼び出されている関数InjectorUtils.provideMainActivityViewModel()は、
//このタイミングで実行される。
//InjectorUtils.provideMainActivityViewModel()では、MusicServiceConnectionを
//インスタンス化したのち、MainActivityViewModelを生成する。
viewModel.navigateToFragment.observe(this, Observer {
//Fragment変更指示の変化を検出した場合の処理
it?.getContentIfNotHandled()?.let { fragmentRequest ->
//Fragmentの置き換え処理を行うために、FragmentManagerを呼び出す。
val transaction = supportFragmentManager.beginTransaction()
//Fragmentを置き換える。すでにFragmentが存在するときは、removeしてからaddする。
transaction.replace(
R.id.fragmentContainer, fragmentRequest.fragment, fragmentRequest.tag
)
//backstackにFragmentを追加する。
//戻るボタンで元の画面に戻るようにする。
if (fragmentRequest.backStack) transaction.addToBackStack(null)
//上記の設定で、Fragmentを変更する。
transaction.commit()
}
})
//ルートメニューIDの設定(MainActivityViewModelのrootMediaId)を監視。
//変化があればMediaItenFragmentをインスタンス化する。
//rootMediaIdがは、アプリが起動し、MediaBrowserServiceに接続すると、nullから"/"に変化する。
viewModel.rootMediaId.observe(this,
//ルートメニューIDの設定の変化を検出した場合の処理
Observer<String> { rootMediaId ->
//navigateToMediaItem()では、MediaItemFragmentのインスタンスを生成したのち、
//Fragmentの変更を要求する。
rootMediaId?.let { navigateToMediaItem(it) }
})
//メニュー変更指示(MainActivityViewModelのnavigateToMediaItem)を監視。
//変化があればFragmentを更新する。
//navigationToMediaItem()の処理で、Fragment変更指示(navigateToFragment)が変更され、
//上で設定したobserverがこれを検知してFragmentの更新処理を行う。
viewModel.navigateToMediaItem.observe(this, Observer {
//メニュー変更指示の変化を検出した場合の処理
it?.getContentIfNotHandled()?.let { mediaId ->
//Fragmentの変更を要求する。
navigateToMediaItem(mediaId)
}
})
}
//以下省略
class MainActivityViewModel(
private val musicServiceConnection: MusicServiceConnection
) : ViewModel() {
//初期化
//MediaBrowserとMediaBrowserServiceとの接続状況(musicServiceConnection.isConnected)を監視。
//.isConnectedがTrueであれば、ルートメニューID(rootMediaId)にmusicServiceConnection.rootMediaIdを設定。
//.isConnectedがTrueでなければ、ルートメニューIDにnullを設定する。
val rootMediaId: LiveData<String> =
Transformations.map(musicServiceConnection.isConnected) { isConnected ->
//Transformations.map()はlazyであり、rootMediaIdの監視が開始されたタイミングで実行される。
if (isConnected) {
musicServiceConnection.rootMediaId
} else {
null
}
}
//途中省略
//メニュー更新指示イベント。
val navigateToMediaItem: LiveData<Event<String>> get() = _navigateToMediaItem
private val _navigateToMediaItem = MutableLiveData<Event<String>>()
//途中省略
//Fragment更新指示イベント。
val navigateToFragment: LiveData<Event<FragmentNavigationRequest>> get() = _navigateToFragment
private val _navigateToFragment = MutableLiveData<Event<FragmentNavigationRequest>>()
//途中省略
//Fragmentの更新を要求する関数。この関数が呼び出されると、Fragment更新指示イベントを発生する。
//イベントは、getContentIfNotHandled()で一度だけ読み出すことが可能。
fun showFragment(fragment: Fragment, backStack: Boolean = true, tag: String? = null) {
_navigateToFragment.value = Event(FragmentNavigationRequest(fragment, backStack, tag))
}
//途中省略
//引数として渡されたMusicServiceConnectionを使用して、MainActivityViewModelを生成する。
class Factory(
private val musicServiceConnection: MusicServiceConnection
) : ViewModelProvider.NewInstanceFactory() {
@Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return MainActivityViewModel(musicServiceConnection) as T
}
}
}
//以下省略
class MediaItemFragment : Fragment() {
//初期化
//途中省略
private val mediaItemFragmentViewModel by viewModels<MediaItemFragmentViewModel> {
//初めてこの変数が使用されるときに初期化される
//provideMediaItemFragmentViewModel()では、MusicServiceConnectionインスタンスの取得と、
//MediaItemFragmentViewModelの生成を行う。
InjectorUtils.provideMediaItemFragmentViewModel(requireContext(), mediaId)
}
//途中省略
companion object {
//MediaItemFragmentの新しいインスタンスを生成。
fun newInstance(mediaId: String): MediaItemFragment {
return MediaItemFragment().apply {
arguments = Bundle().apply {
putString(MEDIA_ID_ARG, mediaId)
}
}
}
}
//ビューの生成
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentMediaitemListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
mediaId = arguments?.getString(MEDIA_ID_ARG) ?: return
//メニュー項目リスト(mediaItemFragmentViewModelのmediaItems)を監視
mediaItemFragmentViewModel.mediaItems.observe(viewLifecycleOwner,
Observer { list ->
//loadSpinner(処理中を示すぐるぐる表示)の表示制御
binding.loadingSpinner.visibility =
//メニュー項目リストが空のときは、loadSpinnerを表示する。
if (list?.isNotEmpty() == true) View.GONE else View.VISIBLE
//メニュー項目を更新する
listAdapter.submitList(list)
})
//途中省略
// Set the adapter
binding.list.adapter = listAdapter
}
}
class MediaItemFragmentViewModel(
private val mediaId: String,
musicServiceConnection: MusicServiceConnection
) : ViewModel() {
//初期化
//途中省略
//MusicServiceConnectionのコールバックを設定
private val subscriptionCallback = object: SubscriptionCallback() {
//階層化されたコンテンツリスト中の指定した階層のコンテンツリストの受信
override fun onChildrenLoaded(parentId: String, children: List<MediaItem>) {
val itemsList = children.map { child ->
//取得したコンテンツリストをExoPlayerで利用可能な形態の楽曲情報のリストに変換
val subtitle = child.description.subtitle ?: ""
MediaItemData(
child.mediaId!!,
child.description.title.toString(),
subtitle.toString(),
child.description.iconUri!!,
child.isBrowsable,
getResourceForMediaId(child.mediaId!!)
)
}
//メニュー項目リストの更新を通知
_mediaItems.postValue(itemsList)
}
}
//再生状態の監視を設定
//再生状態が変化した際に通知する。
private val playbackStateObserver = Observer<PlaybackStateCompat> {
val playbackState = it ?: EMPTY_PLAYBACK_STATE
val metadata = musicServiceConnection.nowPlaying.value ?: NOTHING_PLAYING
if (metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) != null) {
_mediaItems.postValue(updateState(playbackState, metadata))
}
}
//楽曲情報の監視を設定
//再生中の楽曲の情報に変化があった際に通知する。
private val mediaMetadataObserver = Observer<MediaMetadataCompat> {
val playbackState = musicServiceConnection.playbackState.value ?: EMPTY_PLAYBACK_STATE
val metadata = it ?: NOTHING_PLAYING
if (metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID) != null) {
_mediaItems.postValue(updateState(playbackState, metadata))
}
}
//musicServiceConectionをインスタンス化
private val musicServiceConnection = musicServiceConnection.also {
//mediaBrowserServiceに対して、mediaIdで指定した指定した階層のコンテンツリストを要求
//結果は、onChildrenLoadedで受信する。
it.subscribe(mediaId, subscriptionCallback)
//mediaBrowserServiceの再生状態を監視
it.playbackState.observeForever(playbackStateObserver)
//mediaBrowserServiceの再生中の楽曲の情報を監視
it.nowPlaying.observeForever(mediaMetadataObserver)
}
//以下省略
class MusicServiceConnection(context: Context, serviceComponent: ComponentName) {
//初期化
//途中省略
//MediaBrowserServiceの生成、接続
private val mediaBrowser = MediaBrowserCompat(
context,
serviceComponent,
mediaBrowserConnectionCallback, null
//MediaBrowserServiceの生成、接続を実行する。
//接続が完了すると、階層化されたコンテンツリストのルートメディアIDが通知される。
//通知は、onConnectedで受信する。
).apply { connect() }
//メディアコントローラーの宣言。初期化は、実際に使用される際に行われる。
private lateinit var mediaController: MediaControllerCompat
//mediaBrowserServiceに対して、mediaIdで指定した階層のコンテンツリストを要求
fun subscribe(parentId: String, callback: MediaBrowserCompat.SubscriptionCallback) {
mediaBrowser.subscribe(parentId, callback)
}
//途中省略
//MediaBrowserService接続のコールバック
private inner class MediaBrowserConnectionCallback(private val context: Context) :
MediaBrowserCompat.ConnectionCallback() {
//MediaBrowserServiceとの接続が完了すると呼び出される関数
override fun onConnected() {
// メディアセッションに対するMediaControllerの取得
mediaController = MediaControllerCompat(context, mediaBrowser.sessionToken).apply {
registerCallback(MediaControllerCallback())
}
//MediaBrowserServiceとの接続状況を示すフラグの更新
isConnected.postValue(true)
}
//途中省略
companion object {
@Volatile
private var instance: MusicServiceConnection? = null
//インスタンスを通知する関数
fun getInstance(context: Context, serviceComponent: ComponentName) =
instance ?: synchronized(this) {
instance ?: MusicServiceConnection(context, serviceComponent)
.also { instance = it }
}
}
}
//以下省略
open class MusicService : MediaBrowserServiceCompat() {
//初期化
//途中省略
//階層化されたコンテンツリストの生成
private val browseTree: BrowseTree by lazy {
//変数が初めて使用される際に初期化される
BrowseTree(applicationContext, mediaSource)
//途中省略
//アクティビティーの生成
override fun onCreate() {
super.onCreate()
// 通知からアクティビティーを起動するためのIntent(PendingInten)を生成する。
val sessionActivityPendingIntent =
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, 0)
}
//メディアセッションを生成する。
mediaSession = MediaSessionCompat(this, "MusicService")
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
//メディアセッションのトークンを取得する。
sessionToken = mediaSession.sessionToken
//通知管理(notificationManager)を設定する。
notificationManager = UampNotificationManager(
this,
mediaSession.sessionToken,
PlayerNotificationListener()
)
//楽曲カタログを取得し、ExoPlayerで利用可能な楽曲情報リストを生成。
//コルーチンで実施するため、このスレッドではコルーチンを起動して、結果を待たずに処理を続ける。
mediaSource = MusicCatalog(application)
serviceScope.launch {
mediaSource.load()
}
//mediaSessionConnectorの初期化
mediaSessionConnector = MediaSessionConnector(mediaSession)
mediaSessionConnector.setPlaybackPreparer(UampPlaybackPreparer())
mediaSessionConnector.setQueueNavigator(UampQueueNavigator(mediaSession))
//プレイヤーを選択(ExoPlayer or castPlayer)し、メディア再生を準備
switchToPlayer(
previousPlayer = null,
newPlayer = if (castPlayer?.isCastSessionAvailable == true) castPlayer!! else exoPlayer
)
//通知の設定
notificationManager.showNotificationForPlayer(currentPlayer)
//パッケージ検証。
//MediaBrowserがこのMediaBrowserServiceに接続することを承認されているかどうかを検証する。
//検証は、/res/allowed_media_browser_callers.xmlに基づいて行う。
packageValidator = PackageValidator(this, R.xml.allowed_media_browser_callers)
//SharedPreferencesのインスタンス取得
storage = PersistentStorage.getInstance(applicationContext)
}
//途中省略
//connect()に対する応答として、階層化されたコンテンツリストのルートのメディアIDを通知する。
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
//パッケージ検証結果の取得
val isKnownCaller = packageValidator.isKnownCaller(clientPackageName, clientUid)
//Browserに通知する情報の作成
val rootExtras = Bundle().apply {
putBoolean(
MEDIA_SEARCH_SUPPORTED,
isKnownCaller || browseTree.searchableByUnknownCaller
)
putBoolean(CONTENT_STYLE_SUPPORTED, true)
putInt(CONTENT_STYLE_BROWSABLE_HINT, CONTENT_STYLE_GRID)
putInt(CONTENT_STYLE_PLAYABLE_HINT, CONTENT_STYLE_LIST)
}
return if (isKnownCaller) {
val isRecentRequest = rootHints?.getBoolean(EXTRA_RECENT) ?: false
val browserRootPath = if (isRecentRequest) UAMP_RECENT_ROOT else UAMP_BROWSABLE_ROOT
//パッケージ検証の結果、MediaBrowserが承認されていれば、ルートメディアIDとして"/"を返す。
BrowserRoot(browserRootPath, rootExtras)
} else {
//パッケージ検証の結果、MediaBrowserが承認されていなければ、子コンテンツが空のルートメディアIDを返す。
BrowserRoot(UAMP_EMPTY_ROOT, rootExtras)
}
}
//途中省略
//subscribe()への応答として、要求された階層のコンテンツのリストを返す
override fun onLoadChildren(
parentMediaId: String,
result: Result<List<MediaItem>>
) {
if (parentMediaId == UAMP_RECENT_ROOT) {
result.sendResult(storage.loadRecentSong()?.let { song -> listOf(song) })
} else {
val resultsSent = mediaSource.whenReady { successfullyInitialized ->
if (successfullyInitialized) {
//楽曲情報リストの取得が完了したら、submit()の応答として、要求された階層のコンテンツのリストを返す
val children = browseTree[parentMediaId]?.map { item ->
MediaItem(item.description, item.flag)
}
result.sendResult(children)
} else {
//楽曲情報リストの取得に失敗したら、nullを返す。
mediaSession.sendSessionEvent(NETWORK_FAILURE, null)
result.sendResult(null)
}
}
// 楽曲情報リストの取得に失敗したら、メッセージを現在のスレッドから切り離し、後でsendResult()を実行できるようにする。
if (!resultsSent) {
result.detach()
}
}
}
//以下省略
長くなってしまったので、ここで一旦切ります m(_ _)m
アルバムメニューや楽曲メニューの表示、階層化されたコンテンツリストの生成、メニューのカスタマイズの実装などについては改めて投稿いたします。
#参考
Androidデベロッパー 音声と動画
イマドキなAndroid音楽プレーヤーの作り方
ゼロから学ぶメディアプレイヤーの実装
ExoPlayerで音声のみ簡単に再生させる方法
#関連する投稿#
Universal Android Music Player(UAMP)のカスタマイズ (音源の変更 1/2)
Universal Android Music Player(UAMP)のカスタマイズ (音源の変更 2/2)
Universal Android Music Player(UAMP)のカスタマイズ (メニューの変更 2)
#おわりに
プログラミング初心者、AndroidアプリのコードをいじるのもKotlinを使うのも初めてなので、誤りが多々あると思います。アドバイス、励ましのコメントなどいただけると嬉しいです。