アンドロイド スマホのミュージック プレイヤー アプリ(プレイリスト機能無し)
アンドロイド スマホのミュージック プレイヤー アプリ(プレイリスト機能無し)の自作に成功しました。
2023年4月3日時点で、無駄な処理が有るかもしれませんし、通知の処理に自信が無いですし、権限の許可の確認などのコードを全ては網羅できていませんが、スマホのAQUOS sense3と、アマゾンのタブレットのFireHD8Plus(第10世代)で動作確認できたので、下記に書き残しておきます。
著者はアンドロイド11のAQUOS sense3と、アンドロイド9ベースのFireOS7のアマゾンのタブレットのFireHD8Plus(第10世代)しか持っていないため、アンドロイド13以上の場合の、権限の許可の確認などのコードを試す事ができません。
また、権限の許可のダイアログが表示されませんでしたが、アンドロイド11や、アンドロイド9ベースのFireOS7のせいなのか、著者のプログラムのバグなのか、わかりません。
広告、無いです。
アプリ内課金、無いです。
Bluetoothイヤホンで聴く事ができますし、一時停止や次の曲への移動や音量の増減などの操作もできます。
Youtubeアプリといった他の(音楽を含む)音声を再生するアプリが起動した時に、当アプリの音楽の再生を停止します。
電話が、かかってきた時に、当アプリを一時停止して、通話が終了したら、当アプリの音楽の再生を再開します。
現在、再生している分と秒を表示します。
バック グラウンド再生できます。
有線イヤホンが抜けた時に音楽の再生を一時停止します。
Bluetoothイヤホンの場合は、Bluetoothイヤホンの電池が少なく成って警告されるまで数時間連続バック グラウンド再生しましたが、問題無かったです。
有線イヤホンのバック グラウンド再生は、スマホを再起動した直後は、正常に動作しますが、しばらくスマホを使用していると、スリープされてしまいました。もしかしたら、バック グラウンド処理の優先度の設定が必要なのかもしれませんが、現時点では不明です。
音楽ファイルの一覧画面で、音楽ファイルの情報をタップすると、タップされた音楽ファイルの詳細画面が表示されて、音楽を再生する「フォア グラウンド サービス」がアンドロイドの通知に表示されます。
音楽ファイルの詳細画面で再生ボタンを押すと、音楽が再生されます。
音楽ファイルの詳細画面で閉じるボタンを押すと、音楽が停止し、音楽を再生する「フォア グラウンド サービス」が終了されてアンドロイドの通知から消えます。
通知の管理で通知をオフにすると、通知が表示されなく成ります。
ただし、通知が表示されている時に通知をオフにすると、AQUOS sense3ではIndexOutOfBoundsExceptionが発生します。FireHD8Plus(第10世代)ではアプリケーションが強制終了されます。
しかし、アプリを起動し直すと、問題無く動きます。
音楽再生中に、アプリの画面をスワイプして消して、ホーム画面のアプリのアイコンを押して、アプリの画面を呼ぶと、onStart()やonResume()が呼ばれずに画面が復元されるようで、再生中の音楽とは違う音楽ファイルの情報が表示されてしまいます。
ただし、その後、画面を非表示にして再表示すると、再生中の音楽が画面に反映されます。
再生しようとした音楽ファイルが削除されていて読み取れない場合は、アマゾンのタブレットのFireHD8Plus(第10世代)では、音楽ファイルの詳細画面が終了されて、音楽ファイルの一覧画面が表示されて、最新の状態の音楽ファイルの一覧が表示されました。
※試行錯誤中、アンドロイド9ベースのFireOS7のアマゾンのタブレットのFireHD8Plus(第10世代)で、エラーメッセージが表示されずにアプリが強制終了してしまう現象が起きましたが、アンドロイド11のAQUOS sense3ではエラーメッセージが表示されてアプリが終了しなかったので、問題の原因が判明して解決できました。
※なるべく、最新のアンドロイドの端末で、アプリを試験するべきみたいです。
※古いアンドロイドだと、エラーメッセージが表示されなかったり、アプリが強制終了してしまうようです。
※下記のXMLファイルやKotlinのプログラムなどのコードをコピペする場合は、2文字の全角空白を4文字の半角空白に置換してください。
また、Android StudioにJavaやKotlinなどのプログラムのコードをコピペして、「import android.R」が自動で追加されてしまったら、削除してください。
「android.R」は、「R.layout.activity_main」や「R.id.◯◯◯」の「R」とは違います。
そのため、「import android.R」が有ると、コンパイル エラーが発生してしまいます。
Android StudioにJavaやKotlinなどのプログラムのコードをコピペすると、変数の名前が半角バッククォート記号(`)で囲まれる事が有ります。
Kotlinでは変数の名前を半角バッククォート記号(`)で囲むと予約語(inやnullなど)や半角空白記号( )などを変数の名前にできるそうです。
可能であれば、半角バッククォート記号(`)で囲まれた変数の名前は、半角バッククォート記号(`)で囲まずに済む名前に変更したほうが良いのでは、と個人的に思っております。
①「androidx.media」パッケージを利用するのに必要な「implementation "androidx.media:media:1.6.0"」という記述を、Android Studioで、build.gradleの「dependencies」ブロックの中に追加してから、「Sync now」をクリックします。
※build.gradleは少なくとも2つ有るので、そのうち、「dependencies」ブロックが有る適切な1つだけに追加します。
※build.gradleを変更後に、Android Studioで「Sync now」をクリックしないと、「androidx.media」パッケージが認識されません。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/build.gradle
――――――――――――――――――――
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'eliphas1810.freesimplemusicplayer'
compileSdk 33
defaultConfig {
applicationId "eliphas1810.freesimplemusicplayer"
minSdk 24
targetSdk 33
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.media:media:1.6.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
②当アプリがREAD_MEDIA_AUDIO、READ_EXTERNAL_STORAGEという当アプリ外の(音楽を含む)音声メディア ファイルを読み取る権限を必要とするという宣言をアンドロイド スマホのアプリのマニフェスト ファイルに追加します。
③バック グラウンド処理に必要な「フォア グラウンド サービス」の権限の宣言をアンドロイド スマホのアプリのマニフェスト ファイルに追加します。
④バック グラウンドで音楽を再生中に、画面がスリープされても、CPUがスリープされないようにする設定処理を書くのに必要な、WAKE_LOCKの権限の宣言をアンドロイド スマホのアプリのマニフェスト ファイルに追加します。
⑤音楽ファイルの一覧画面から、選択された一件の音楽ファイルの詳細画面へ画面遷移する設定をアンドロイド スマホのアプリのマニフェスト ファイルに追加します。
⑥「サービス」の設定をアンドロイド スマホのアプリのマニフェスト ファイルに追加します。
⑦Bluetoothイヤホンなどの物理ボタンである「メディア ボタン」の操作イベントを「メディア セッション」から受け取るための「android.media.browse.MediaBrowserService」の設定をアンドロイド スマホのアプリのマニフェスト ファイルの「サービス」の設定に追加します。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/AndroidManifest.xml
――――――――――――――――――――
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.FreeSimpleMusicPlayer"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MusicDetail"
android:parentActivityName=".MainActivity"
>
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".MainActivity"
/>
</activity>
<service
android:name=".MusicPlayerService"
android:enabled="true"
android:exported="true"
>
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
</application>
</manifest>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑧画面に表示する文字のデフォルト設定のXMLファイルに、ボタンに表示する文字や、ダイアログに表示するメッセージを追加します。
・画面に表示する文字をプログラムの中に書くと他言語に対応して文字を変更して表示できないので、アンドロイド スマホのアプリは各言語に対応した文字を画面に表示するローカライズという仕組みを提供してくれていて、画面に表示する文字のデフォルト設定を書くXMLであるres/values/strings.xmlが用意されています。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/values/strings.xml
――――――――――――――――――――
<resources>
<string name="app_name">FreeSimpleMusicPlayer</string>
<string name="minutesUnitLabel">minutes</string>
<string name="secondsAndMilliSecondsSeparator">.</string>
<string name="secondsUnitLabel">seconds</string>
<string name="music_detail_close">Close</string>
<string name="music_detail_start">Play</string>
<string name="music_detail_pause">Pause</string>
<string name="music_detail_stop">Stop</string>
<string name="music_detail_previous">Previous</string>
<string name="music_detail_next">Next</string>
<string name="music_detail_loop">Loop Mode</string>
<string name="music_detail_random">Random Mode</string>
<string name="no_music_file_list">Music files are not found. (1)Please finish this application once. (2)Please move or copy music files to storages of this smart phone, tablet and so on. (3)Please restart this application.</string>
<string name="ok">OK</string>
<string name="denied_read_media_audio">Now this application does not have the permission to read audio media files( including music files) by others applications. So this applicatiopn can not read music files and show music files. Please finish this application.</string>
<string name="denied_read_external_storage">Now this application does not have the permission to read files( including music files) by others applications. So this applicatiopn can not read music files and show music files. Please finish this application.</string>
<string name="notification_content_title">Notification title</string>
<string name="notification_content_text">Notification text</string>
<string name="notification_ticker">Notification ticker</string>
</resources>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑨画面に表示する文字の日本語対応の設定のXMLファイルを置くres/values-ja/ディレクトリを用意します。
――――――――――――――――――――
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/values-ja/
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑩画面に表示する文字の日本語対応の設定のXMLファイルに、ボタンに表示する文字や、ダイアログに表示するメッセージを追加します。
・アンドロイド システムは日本語の場合はres/values/strings.xmlではなくres/values-ja/strings.xmlの文字を表示してくれます。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/values-ja/strings.xml
――――――――――――――――――――
<resources>
<string name="app_name">FreeSimpleMusicPlayer</string>
<string name="minutesUnitLabel">分</string>
<string name="secondsAndMilliSecondsSeparator">.</string>
<string name="secondsUnitLabel">秒</string>
<string name="music_detail_close">閉じる</string>
<string name="music_detail_start">再生</string>
<string name="music_detail_pause">一時停止</string>
<string name="music_detail_stop">停止</string>
<string name="music_detail_previous">前の曲へ</string>
<string name="music_detail_next">次の曲へ</string>
<string name="music_detail_loop">ループ モード</string>
<string name="music_detail_random">ランダム モード</string>
<string name="no_music_file_list">音楽ファイルが見つかりません。当アプリを一旦、終了してください。音楽ファイルをストレージに保存して、再度、当アプリを起動してください。</string>
<string name="ok">わかりました</string>
<string name="denied_read_media_audio">当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可が無いので、当アプリは音楽ファイルを表示して再生できません。当アプリを終了してください。</string>
<string name="denied_read_external_storage">音楽ファイルを含む当アプリ以外によるファイルを読み取る権限の許可が無いので、当アプリは音楽ファイルを表示して再生できません。当アプリを終了してください。</string>
<string name="notification_content_title">通知の見出し</string>
<string name="notification_content_text">通知文</string>
<string name="notification_ticker">通知ティッカー</string>
</resources>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑪色の設定のXMLファイルに、色の設定を追加します。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/values/colors.xml
――――――――――――――――――――
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="gray">#FF808080</color><!-- #AARRGGBB。アルファ値のFF = 255は不透明。 -->
<color name="silver">#FFC0C0C0</color>
<color name="lime">#FF00FF00</color>
</resources>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑫一覧表示のListViewの中で表示する、一行分の一件分の画面の表示の設定のXMLファイルを新規作成します。
・android:layout_gravity="center"は中央寄せです。
・ちなみに、最も分かりやすいリニア レイアウト(LinearLayout)にしております。
アンドロイド スマホのアプリ開発の初心者は最初はリニア レイアウト(LinearLayout)が適していると個人的には思います。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/layout/music_list_row.xml
――――――――――――――――――――
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<TextView
android:id="@+id/musicListMusicTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicListArtistName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicListAlbumTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicListMusicFilePath"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicListMusicDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
</LinearLayout>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑬既存のメインのXMLファイルに一覧表示のListViewを追加します。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/layout/activity_main.xml
――――――――――――――――――――
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<ListView
android:id="@+id/musicList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
</LinearLayout>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑭選択された一件の音楽ファイルの詳細画面の表示の設定のXMLファイルを新規作成します。
・「@string/名前」でstrings.xmlの設定を利用できます。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/res/layout/music_detail.xml
――――――――――――――――――――
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
>
<Button
android:id="@+id/musicDetailClose"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_close"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicDetailMusicTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicDetailArtistName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicDetailAlbumTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicDetailMusicFilePath"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<TextView
android:id="@+id/musicDetailMusicDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<SeekBar
android:id="@+id/musicDetailMusicCurrentDurationSeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
<TextView
android:id="@+id/musicDetailMusicCurrentDuration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailStart"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_start"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailPause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_pause"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailStop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_stop"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailPrevious"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_previous"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailNext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_next"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailLoop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_loop"
android:layout_gravity="center"
/>
<Button
android:id="@+id/musicDetailRandom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/music_detail_random"
android:layout_gravity="center"
/>
</LinearLayout>
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
⑮アンドロイド スマホのアプリの、音楽ファイルの一覧画面に1対1対応しているアクティビティという部分で「メディア ストア」と呼ばれる物から音楽ファイルの一覧の情報を取得します。
・アンドロイド システムは自動でストレージのファイルをスキャンして「メディア ストア」という名前でアンドロイド スマホのアプリがアクセスできるように情報を提供してくれています。
・MediaStore.Imagesは、DCIM/、Pictures/ディレクトリの画像ファイルの情報を提供してくれます。
・MediaStore.Videoは、DCIM/、Pictures/、Movies/ディレクトリの動画ファイルの情報を提供してくれます。
・MediaStore.Audioは、Alarms/、Audiobooks/、Music/、Notifications/、Podcasts/、Ringtones/、Movies/、Recordings/ディレクトリの(音楽を含む)音声ファイルの情報を提供してくれます。
・MediaStore.Downloadsは、Download/ディレクトリのファイルの情報を提供してくれます。
・MediaStore.Filesは、Androidアプリによって作成されたファイルの情報を提供してくれたりするそうです。
・「メディア ストア」からはSQL風の命令文で情報を取得します。
・アンドロイド スマホのアプリの画面遷移で次の画面に情報を渡す場合、文字(String)や数値(Intなど)の基本データ型か、複数の基本データ型をひとまとめにしているandroid.os.Parcelableを実装した型か、android.os.Parcelable型の配列の型か、android.os.Parcelable型のjava.io.Serializableとjava.util.Listを実装しているような型の情報だけが渡せます。
・Android 7でアンドロイド スマホのアプリの画面遷移で次の画面にjava.io.Serializable型の情報を渡せなく成り、代わりにandroid.os.Parcelableを利用しなければいけません。
・ちなみに、java.util.Listはjava.io.Serializableを継承していないようです。java.util.ArrayListはjava.io.Serializableを実装しているようです。
・権限の許可を確認しています。
・android.os.Parcelableを実装しているデータ クラス(data class)によって曲名やアーティスト名といった情報をひとまとめにしています。
・アダプターと呼ばれるクラスのサブクラス(ArrayAdapter)の、サブクラスを作って、getViewという処理の中で、music_list_row.xmlにデータ クラスの中の曲名やアーティスト名などの情報を設定する処理を書いています。
・ListView.onItemClickListenerにOnItemClickListenerの実装を設定し、OnItemClickListenerの処理の中で、ゼロから始まる選択された行の番号の情報を受け取り、アンドロイド スマホのアプリの画面遷移で情報を渡すのに利用するIntentを作成し、音楽ファイルの一覧の情報と、一覧でのゼロから始まる選択された音楽ファイルの番号をIntentに設定し、startActivity(intent)によって次の画面のアクティビティを起動しています。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/java/eliphas1810/freesimplemusicplayer/MainActivity.kt
――――――――――――――――――――
package eliphas1810.freesimplemusicplayer
import android.Manifest
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.Creator
import android.provider.MediaStore
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView.OnItemClickListener
import android.widget.ArrayAdapter
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
//音楽ファイルの情報をひとまとめにした物
//
//Parcelableはアクティビティとサービス間で送信し合う事ができる情報
//
data class MusicInfo(
var id: Long?,
var musicTitle: String?,
var artistName: String?,
var albumTitle: String?,
var filePath: String?,
var duration: Int? //音楽の所要時間
) : Parcelable {
constructor(parcel: Parcel) : this(0, null, null, null, null, 0) {
id = parcel.readLong()
musicTitle = parcel.readString()
artistName = parcel.readString()
albumTitle = parcel.readString()
filePath = parcel.readString()
duration = parcel.readInt()
}
companion object {
@field: JvmField
val CREATOR: Creator<MusicInfo?> = object : Creator<MusicInfo?> {
override fun createFromParcel(parcel: Parcel): MusicInfo? {
return MusicInfo(parcel)
}
override fun newArray(size: Int): Array<MusicInfo?> {
return arrayOfNulls<MusicInfo>(size)
}
}
}
override fun writeToParcel(parcel: Parcel, flags: Int) { //コンストラクタでParcelから取得する順番と同じ順番でParcelに書き込む必要が有ります。
parcel.writeLong(id ?: 0) //idがnullの場合はゼロ
parcel.writeString(musicTitle ?: "null") //曲名がnullの場合は「null」という文字
parcel.writeString(artistName ?: "null")
parcel.writeString(albumTitle ?: "null")
parcel.writeString(filePath ?: "null")
parcel.writeInt(duration ?: 0)
}
//Parcel.describeContents()は普通はゼロを返すように実装
override fun describeContents(): Int {
return 0
}
}
//音楽ファイルの一覧画面で、Listの各件の内容をListViewの各行に設定する物
private class Adapter(context: Context, list: List<MusicInfo>) : ArrayAdapter<MusicInfo>(context, R.layout.music_list_row, list) {
//例えば、「1分02.003秒」という形式に音楽の総再生時間を編集
fun convertMusicDurationToText(musicDuration: Int) : String {
val minutes = musicDuration / (1000 * 60)
val seconds = (musicDuration % (1000 * 60)) / 1000
val milliSeconds = musicDuration % 1000
var durationText = ""
durationText = durationText + minutes
durationText = durationText + context.getString(R.string.minutesUnitLabel)
durationText = durationText + seconds
durationText = durationText + context.getString(R.string.secondsAndMilliSecondsSeparator)
durationText = durationText + "%03d".format(milliSeconds)
durationText = durationText + context.getString(R.string.secondsUnitLabel)
return durationText
}
//音楽ファイルの一覧画面で、Listの各件の内容をListViewの各行に設定
override fun getView(position: Int, view: View?, viewGroup: ViewGroup): View {
var view: View? = view
try {
if (view == null) {
view = LayoutInflater.from(context).inflate(R.layout.music_list_row, viewGroup, false)
}
val musicInfo = getItem(position)
view?.findViewById<TextView>(R.id.musicListMusicTitle)?.text = musicInfo?.musicTitle
view?.findViewById<TextView>(R.id.musicListArtistName)?.text = musicInfo?.artistName
view?.findViewById<TextView>(R.id.musicListAlbumTitle)?.text = musicInfo?.albumTitle
view?.findViewById<TextView>(R.id.musicListMusicFilePath)?.text = musicInfo?.filePath
view?.findViewById<TextView>(R.id.musicListMusicDuration)?.text = convertMusicDurationToText(musicInfo?.duration ?: 0)
} catch (exception: Exception) {
Toast.makeText(view?.context?.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
return view!!
}
}
//音楽ファイル一覧画面と1対1対応のアクティビティ
class MainActivity : AppCompatActivity() {
companion object {
//音楽ファイル一覧画面のアクティビティから、音楽ファイル詳細画面のアクティビティへ送信する情報の名前
//
//重複を避けるため、「パッケージ名 + 情報の名前」
//
const val MUSIC_INFO_LIST_KEY = "eliphas1810.freesimplemusicplayer.MUSIC_INFO_LIST"
const val MUSIC_INFO_INDEX_KEY = "eliphas1810.freesimplemusicplayer.MUSIC_INFO_INDEX"
//アンドロイド アプリ開発者が管理する場合の、権限の許可のリクエストコードは、アンドロイド アプリ開発者の責任で重複させない事
private const val READ_MEDIA_AUDIO_REQUEST_CODE = 1
private const val READ_EXTERNAL_STORAGE_REQUEST_CODE = 2
}
private var musicInfoList: MutableList<MusicInfo>? = null
//音楽ファイルを一覧検索して、音楽ファイル一覧画面の内容を設定
private fun listMusicFile() {
try {
if (musicInfoList != null && 1 <= (musicInfoList?.size ?: 0)) {
musicInfoList?.clear()
}
musicInfoList = mutableListOf<MusicInfo>()
//当アプリ以外によるファイルの取得先
val externalContentUri =
if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) { //アンドロイド10(Q)以上の場合
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
getContentResolver().query(
externalContentUri, //当アプリ以外によるファイルの取得先
arrayOf( //SQLのSELECTに相当
MediaStore.Audio.Media._ID, //android.media.MediaPlayerへの曲の指定に必要なID
MediaStore.Audio.Media.TITLE, //音楽ファイルの曲名を取得
MediaStore.Audio.Media.ARTIST, //音楽ファイルのアーティスト名を取得
MediaStore.Audio.Media.ALBUM, //音楽ファイルのアルバム名を取得
MediaStore.Audio.Media.DATA, //音楽ファイルのパスを取得
MediaStore.Audio.Media.DURATION //音楽ファイルの総再生時間を取得
),
"${MediaStore.Audio.Media.IS_MUSIC} != 0", //SQLのWHEREに相当。音楽ファイルに限定して一覧検索。
null, //SQLのWHEREの?への指定パラメーターに相当
"${MediaStore.Audio.Media.TITLE} ASC" //SQLのORDER BYに相当
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val filePathIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
while (cursor.moveToNext()) {
var id = cursor.getLong(idIndex)
var title = cursor.getString(titleIndex)
var artist = cursor.getString(artistIndex)
var album = cursor.getString(albumIndex)
var filePath = cursor.getString(filePathIndex)
var duration = cursor.getInt(durationIndex)
musicInfoList?.add(MusicInfo(id, title, artist, album, filePath, duration))
}
}
//音楽ファイルが見つからない場合
if ((musicInfoList?.size ?: 0) <= 0) {
Toast.makeText(this, getString(R.string.no_music_file_list), Toast.LENGTH_LONG).show()
}
//音楽ファイルの一覧画面で、ListViewの各行の内容を設定する物を指定
val listView = findViewById<ListView>(R.id.musicList)
listView.adapter = Adapter(this, musicInfoList ?: mutableListOf<MusicInfo>())
//音楽ファイルの一覧画面で、ListViewの各行が押された時の処理
listView.onItemClickListener = OnItemClickListener { adapterView, view, position, id ->
try {
//音楽ファイルの詳細画面へ遷移
val intent = Intent(this, MusicDetail::class.java)
intent.putExtra(MUSIC_INFO_LIST_KEY, ArrayList(musicInfoList))
intent.putExtra(MUSIC_INFO_INDEX_KEY, position)
startActivity(intent)
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//権限の許可の要求の結果が出た時に呼ばれます。
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
try {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可の有無が選択された場合
if (requestCode == READ_MEDIA_AUDIO_REQUEST_CODE) {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限を許可された場合
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
//音楽ファイルを一覧検索して、音楽ファイル一覧画面の内容を設定
listMusicFile()
return
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限を許可されなかった場合
} else {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可が無いので、当アプリを実行できない事を説明して、処理を終了
AlertDialog.Builder(this)
.setMessage(getString(R.string.denied_read_media_audio))
.setPositiveButton(getString(R.string.ok)) { _, _ ->
}
.create()
.show()
return
}
}
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可の有無が選択された場合
if (requestCode == READ_EXTERNAL_STORAGE_REQUEST_CODE) {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限を許可された場合
if ((grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)) {
//音楽ファイルを一覧検索して、音楽ファイル一覧画面の内容を設定
listMusicFile()
return
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限を許可されなかった場合
} else {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可が無いので、当アプリを実行できない事を説明して、処理を終了
AlertDialog.Builder(this)
.setMessage(getString(R.string.denied_read_external_storage))
.setPositiveButton(getString(R.string.ok)) { _, _ ->
}
.create()
.show()
return
}
}
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//メモリー上に作成される時にのみ呼ばれます。
override fun onCreate(savedInstanceState: Bundle?) {
try {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
//権限の許可の確認
//当アプリ以外によるファイルを読み取る権限の許可が無い場合
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED) {
//アンドロイド ティラミス以上の場合
//アンドロイド13以上の場合
if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) {
//アンドロイド13以降、Manifest.permission.READ_MEDIA_AUDIOが存在
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可が無い場合
if (checkSelfPermission(Manifest.permission.READ_MEDIA_AUDIO) == PackageManager.PERMISSION_DENIED) {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可ダイアログで「今後、表示しない」を未選択の場合
if (shouldShowRequestPermissionRationale(Manifest.permission.READ_MEDIA_AUDIO)) {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可ダイアログを表示して、onRequestPermissionsResultで選択結果を受け取る
requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_AUDIO), READ_MEDIA_AUDIO_REQUEST_CODE)
//一旦、当処理は終了
return
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可ダイアログで「今後、表示しない」を選択中の場合
} else {
//当アプリ以外による音楽を含む音声メディア ファイルを読み取る権限の許可が無いので、当アプリを実行できない事を説明して、処理を終了
AlertDialog.Builder(this)
.setMessage(getString(R.string.denied_read_media_audio))
.setPositiveButton(getString(R.string.ok)) { _, _ ->
}
.create()
.show()
return
}
}
//アンドロイド12以下の場合
} else {
//当アプリ以外によるファイルを読み取る権限の許可ダイアログで「今後、表示しない」を未選択の場合
if (shouldShowRequestPermissionRationale(Manifest.permission.READ_EXTERNAL_STORAGE)) {
//当アプリ以外によるファイルを読み取る権限の許可ダイアログを表示して、onRequestPermissionsResultで選択結果を受け取る
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), READ_EXTERNAL_STORAGE_REQUEST_CODE)
//一旦、当処理は終了
return
//当アプリ以外によるファイルを読み取る権限の許可ダイアログで「今後、表示しない」を選択中の場合
} else {
//当アプリ以外によるファイルを読み取る権限の許可が無いので、当アプリを実行できない事を説明して、処理を終了
AlertDialog.Builder(this)
.setMessage(getString(R.string.denied_read_external_storage))
.setPositiveButton(getString(R.string.ok)) { _, _ ->
}
.create()
.show()
return
}
}
}
//音楽ファイルを一覧検索して、音楽ファイル一覧画面の内容を設定
listMusicFile()
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//メモリーから破棄される時にのみ呼ばれます。
override fun onDestroy() {
try {
musicInfoList?.clear()
musicInfoList = null
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
} finally {
super.onDestroy()
}
}
}
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
eliphas1810/freesimplemusicplayerは著者が付けたJavaやKotlinのプログラムのパッケージのディレクトリの相対パスです。
eliphas1810.freesimplemusicplayerは著者が付けたJavaやKotlinのプログラムのパッケージの名前です。
⑯アンドロイド スマホのアプリの、選択された一件の音楽ファイルの詳細画面に1対1対応しているアクティビティという部分から、バック グラウンドなどで音楽を再生したりする「サービス」へ情報を送信します。
・「サービス」からの、音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知を「ブロードキャスト レシーバー」のサブクラス経由で受け取ります。
・「サービス」からの、音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知を「ブロードキャスト レシーバー」のサブクラス経由で受け取ります。
・startForegroundService(intent: Intent)で、「(フォア グラウンド )サービス」を起動します。
・「サービス」がまだ無い場合は新規作成されます。
※「フォア グラウンド サービス」ではない「サービス」は、バック グラウンドのまま30分くらい経つと、アンドロイド システムに強制終了されてしまいます。
※「フォア グラウンド サービス」はstartForegroundService(intent: Intent)から5秒以内にstartForeground(flag: Int, notification: Notification)を呼ぶ必要が有ります。
※startForeground(flag: Int, notification: Notification)を呼ばないと、startForegroundService(intent: Intent)から5秒が経ったら、アンドロイド システムに強制終了されてしまいます。
※通知のNotificationクラスは、通知チャンネルのNotificationChannelを作成する必要が有ります。
・bindService()で「サービス」へ接続します。
・音楽ファイルの一覧画面から、音楽ファイルの一覧の情報と、一覧でのゼロから始まる選択された音楽ファイルの番号を受け取って、音楽ファイルの詳細画面の内容を設定します。
・閉じるボタンが押された時の処理の中で、finish()を実行して前の画面へ戻ります。
・通知を受けた時や、ボタンが押された時の処理で、サービスへ情報を送信します。
※ちなみに、Kotlin言語では、クロージャーなどで、ブロック({})の外の変数を参照したり変更したりできます。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/java/eliphas1810/freesimplemusicplayer/MusicDetail.kt
――――――――――――――――――――
package eliphas1810.freesimplemusicplayer
import android.content.*
import android.os.*
import android.widget.Button
import android.widget.SeekBar
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
//音楽ファイル詳細画面と1対1対応のアクティビティ
class MusicDetail : AppCompatActivity() {
companion object {
//音楽ファイル一覧画面のアクティビティから、音楽ファイル詳細画面のアクティビティへ送信する情報の名前
//当アクティビティから、音楽を再生したりするサービスへ送信する情報の名前
//サービスからの通知情報の名前
//
//重複を避けるため、「パッケージ名 + 情報の名前」
//
const val MUSIC_INFO_LIST_KEY = "eliphas1810.freesimplemusicplayer.MUSIC_INFO_LIST"
const val MUSIC_INFO_INDEX_KEY = "eliphas1810.freesimplemusicplayer.MUSIC_INFO_INDEX"
const val LOOP_MUSIC_KEY = "eliphas1810.freesimplemusicplayer.LOOP_MUSIC"
const val RANDOM_MUSIC_KEY = "eliphas1810.freesimplemusicplayer.RANDOM_MUSIC"
const val CURRENT_MUSIC_DURATION_KEY = "eliphas1810.freesimplemusicplayer.CURRENT_MUSIC_DURATION"
//音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促される通知の名前
const val UPDATE_MUSIC_INFO_KEY = "eliphas1810.freesimplemusicplayer.UPDATE_MUSIC_INFO"
//音楽ファイルの詳細画面に表示する現在の再生時間の更新を促される通知の名前
const val UPDATE_MUSIC_CURRENT_DURATION_KEY = "eliphas1810.freesimplemusicplayer.UPDATE_MUSIC_CURRENT_DURATION"
//当アクティビティから、音楽を再生したりするサービスへの送信情報のコードは、アンドロイド アプリ開発者の責任で重複させない事
const val START_MUSIC_MESSAGE = 1
const val PAUSE_MUSIC_MESSAGE = 2
const val STOP_MUSIC_MESSAGE = 3
const val PREVIOUS_MUSIC_MESSAGE = 4
const val NEXT_MUSIC_MESSAGE = 5
const val SEEK_MUSIC_MESSAGE = 6
const val LOOP_MUSIC_MESSAGE = 7
const val RANDOM_MUSIC_MESSAGE = 8
const val REQUEST_MUSIC_INFO_MESSAGE = 9
}
var musicInfoList: ArrayList<MusicInfo>? = null
var musicInfoIndex = 0
var loop = false
var random = false
var currentMusicDuration = 0
var musicCurrentDurationChanging = false
var connectingWithService: Boolean = false
private var serviceConnection: ServiceConnection? = null
var messenger: Messenger? = null
private var musicInfoUpdateBroadcastReceiver: BroadcastReceiver? = null
private var musicCurrentDurationUpdateBroadcastReceiver: BroadcastReceiver? = null
//例えば、「1分2.003秒」といった形式に音楽の再生時間を編集
fun convertMusicDurationToText(musicDuration: Int) : String {
val minutes = musicDuration / (1000 * 60)
val seconds = (musicDuration % (1000 * 60)) / 1000
val milliSeconds = musicDuration % 1000
var musicDurationText = ""
musicDurationText = musicDurationText + minutes
musicDurationText = musicDurationText + getString(R.string.minutesUnitLabel)
musicDurationText = musicDurationText + seconds
musicDurationText = musicDurationText + getString(R.string.secondsAndMilliSecondsSeparator)
musicDurationText = musicDurationText + "%03d".format(milliSeconds)
musicDurationText = musicDurationText + getString(R.string.secondsUnitLabel)
return musicDurationText
}
//メモリー上に作成される時にのみ呼ばれます。
override fun onCreate(savedInstanceState: Bundle?) {
try {
super.onCreate(savedInstanceState)
setContentView(R.layout.music_detail)
if (musicInfoList != null && 1 <= (musicInfoList?.size ?: 0)) {
musicInfoList?.clear()
}
musicInfoList = ArrayList(listOf())
serviceConnection = object: ServiceConnection {
override fun onServiceConnected(componentName: ComponentName, iBinder: IBinder) {
messenger = Messenger(iBinder)
connectingWithService = true
}
override fun onServiceDisconnected(componentName: ComponentName) {
messenger = null
connectingWithService = false
}
}
//アンドロイド8(オレオ)以上の場合
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
//アンドロイド アプリのバックグラウンド処理の部分である「サービス」と呼ばれる物を起動
//
//音楽を再生する「サービス」を起動
//
//音楽を再生する「サービス」がまだ無い場合は新規作成されます。
//
startForegroundService(Intent(applicationContext, MusicPlayerService::class.java))
} else {
//アンドロイド アプリのバックグラウンド処理の部分である「サービス」と呼ばれる物を起動
//
//音楽を再生する「サービス」を起動
//
//音楽を再生する「サービス」がまだ無い場合は新規作成されます。
//
startService(Intent(applicationContext, MusicPlayerService::class.java))
}
//アンドロイド アプリのバックグラウンド処理の部分である「サービス」と呼ばれる物へ接続
//
//音楽を再生する「サービス」へ接続
//
//音楽を再生する「サービス」がまだ無い場合は新規作成されます。
//
bindService(Intent(applicationContext, MusicPlayerService::class.java), serviceConnection!!, Context.BIND_AUTO_CREATE)
//サービスからの、音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知を受け取ります。
musicInfoUpdateBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
try {
val bundle = intent?.extras
//サービスからの通知情報から、音楽ファイルの一覧の情報を受け取ります。
musicInfoList = if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) { //アンドロイド13(ティラミス)以上の場合
bundle?.getParcelableArrayList(MUSIC_INFO_LIST_KEY, MusicInfo::class.java)
} else {
bundle?.getParcelableArrayList(MUSIC_INFO_LIST_KEY)
}
if ((musicInfoList?.size ?: 0) <= 0) {
return
}
//サービスからの通知情報から、選択中のゼロから始まる音楽ファイルの番号を受け取ります。
musicInfoIndex = bundle?.getInt(MUSIC_INFO_INDEX_KEY) ?: 0
//サービスからの通知情報から、ループ再生するか否かを受け取ります。
loop = bundle?.getBoolean(LOOP_MUSIC_KEY) ?: false
//サービスからの通知情報から、ループ再生するか否かを受け取ります。
random = bundle?.getBoolean(RANDOM_MUSIC_KEY) ?: false
//音楽ファイルの詳細画面の内容を更新
val musicInfo = musicInfoList?.get(musicInfoIndex)
findViewById<TextView>(R.id.musicDetailMusicTitle)?.text = musicInfo?.musicTitle
findViewById<TextView>(R.id.musicDetailArtistName)?.text = musicInfo?.artistName
findViewById<TextView>(R.id.musicDetailAlbumTitle)?.text = musicInfo?.albumTitle
findViewById<TextView>(R.id.musicDetailMusicFilePath)?.text = musicInfo?.filePath
findViewById<TextView>(R.id.musicDetailMusicDuration)?.text = convertMusicDurationToText(musicInfo?.duration ?: 0)
if (loop) {
findViewById<Button>(R.id.musicDetailLoop)?.setTextColor(resources.getColor(R.color.white, theme))
findViewById<Button>(R.id.musicDetailLoop)?.setBackgroundColor(resources.getColor(R.color.lime, theme))
} else {
findViewById<Button>(R.id.musicDetailLoop)?.setTextColor(resources.getColor(R.color.silver, theme))
findViewById<Button>(R.id.musicDetailLoop)?.setBackgroundColor(resources.getColor(R.color.gray, theme))
}
if (random) {
findViewById<Button>(R.id.musicDetailRandom)?.setTextColor(resources.getColor(R.color.white, theme))
findViewById<Button>(R.id.musicDetailRandom)?.setBackgroundColor(resources.getColor(R.color.lime, theme))
} else {
findViewById<Button>(R.id.musicDetailRandom)?.setTextColor(resources.getColor(R.color.silver, theme))
findViewById<Button>(R.id.musicDetailRandom)?.setBackgroundColor(resources.getColor(R.color.gray, theme))
}
//音楽ファイルの詳細画面の、現在の再生時間のシーク バーが動かされていない場合
if (musicCurrentDurationChanging == false) {
//サービスからの通知情報から、現在の再生時間を受け取ります。
currentMusicDuration = bundle?.getInt(CURRENT_MUSIC_DURATION_KEY) ?: 0
//音楽ファイルの詳細画面の、現在の再生時間のシーク バーを更新
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).max = musicInfo?.duration ?: 0
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).progress = currentMusicDuration
//音楽ファイルの詳細画面の、現在の再生時間を更新
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
}
} catch (exception: Exception) {
Toast.makeText(context?.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
}
registerReceiver(musicInfoUpdateBroadcastReceiver, IntentFilter(UPDATE_MUSIC_INFO_KEY))
//サービスからの、音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知を受け取ります。
musicCurrentDurationUpdateBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
try {
//音楽ファイルの詳細画面の、現在の再生時間のシーク バーが動かされていない場合
if (musicCurrentDurationChanging == false) {
val bundle = intent?.extras
//サービスからの通知情報から、音楽ファイルの一覧の情報を受け取ります。
musicInfoList = if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) { //アンドロイド13(ティラミス)以上の場合
bundle?.getParcelableArrayList(MUSIC_INFO_LIST_KEY, MusicInfo::class.java)
} else {
bundle?.getParcelableArrayList(MUSIC_INFO_LIST_KEY)
}
//サービスからの通知情報から、選択中のゼロから始まる音楽ファイルの番号を受け取ります。
musicInfoIndex = bundle?.getInt(MUSIC_INFO_INDEX_KEY) ?: 0
val musicInfo = musicInfoList?.get(musicInfoIndex)
//サービスからの通知情報から、現在の再生時間を受け取ります。
currentMusicDuration = bundle?.getInt(CURRENT_MUSIC_DURATION_KEY) ?: 0
//音楽ファイルの詳細画面の、現在の再生時間のシーク バーを更新
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).max = musicInfo?.duration ?: 0
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).progress = currentMusicDuration
//音楽ファイルの詳細画面の、現在の再生時間を更新
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
}
} catch (exception: Exception) {
Toast.makeText(context?.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
}
registerReceiver(musicCurrentDurationUpdateBroadcastReceiver, IntentFilter(UPDATE_MUSIC_CURRENT_DURATION_KEY))
//前の画面から音楽ファイルの一覧を取得
musicInfoList = if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) { //アンドロイド13(ティラミス)以上の場合
ArrayList(intent.getParcelableArrayListExtra(MUSIC_INFO_LIST_KEY, MusicInfo::class.java))
} else {
ArrayList(intent.getParcelableArrayListExtra(MUSIC_INFO_LIST_KEY))
}
//前の画面から選択されたゼロから始まる音楽ファイルの番号を取得
musicInfoIndex = intent.getIntExtra(MUSIC_INFO_INDEX_KEY, 0)
val musicInfo = musicInfoList?.get(musicInfoIndex)
//画面の1回目の表示時の処理
findViewById<TextView>(R.id.musicDetailMusicTitle)?.text = musicInfo?.musicTitle
findViewById<TextView>(R.id.musicDetailArtistName)?.text = musicInfo?.artistName
findViewById<TextView>(R.id.musicDetailAlbumTitle)?.text = musicInfo?.albumTitle
findViewById<TextView>(R.id.musicDetailMusicFilePath)?.text = musicInfo?.filePath
findViewById<TextView>(R.id.musicDetailMusicDuration)?.text = convertMusicDurationToText(musicInfo?.duration ?: 0)
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).max = musicInfo?.duration ?: 0
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).progress = currentMusicDuration
findViewById<Button>(R.id.musicDetailLoop).setTextColor(resources.getColor(R.color.silver, theme))
findViewById<Button>(R.id.musicDetailLoop).setBackgroundColor(resources.getColor(R.color.gray, theme))
findViewById<Button>(R.id.musicDetailRandom).setTextColor(resources.getColor(R.color.silver, theme))
findViewById<Button>(R.id.musicDetailRandom).setBackgroundColor(resources.getColor(R.color.gray, theme))
//閉じるボタンが押された時の処理
findViewById<Button>(R.id.musicDetailClose).setOnClickListener { view ->
try {
//音楽を再生する「サービス」に、音楽を停止するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, STOP_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
//音楽を再生する「サービス」を終了させます。
stopService(Intent(applicationContext, MusicPlayerService::class.java))
//前の画面へ戻る
finish()
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//音楽ファイルの詳細画面の、現在の再生時間のシーク バーが操作された時の処理
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(seekBar: SeekBar) {
musicCurrentDurationChanging = true
//音楽を再生する「サービス」に、音楽を一時停止するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, PAUSE_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
}
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromTouch: Boolean) {
currentMusicDuration = progress
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
//音楽を再生する「サービス」に、音楽の再生開始時間を指定して、再生するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, SEEK_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
musicCurrentDurationChanging = false
}
})
//再生ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailStart).setOnClickListener { view ->
try {
//音楽を再生する「サービス」に、音楽を再生するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, START_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//一時停止ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailPause).setOnClickListener { view ->
try {
//音楽を再生する「サービス」に、音楽を一時停止するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, PAUSE_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//停止ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailStop).setOnClickListener { view ->
try {
currentMusicDuration = 0
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).progress = currentMusicDuration
//音楽を再生する「サービス」に、音楽を停止するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, STOP_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//「前の曲へ戻る」ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailPrevious).setOnClickListener { view ->
try {
currentMusicDuration = 0
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).progress = currentMusicDuration
//音楽を再生する「サービス」に、前の曲へ戻って再生するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, PREVIOUS_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//「次の曲へ進む」ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailNext).setOnClickListener { view ->
try {
currentMusicDuration = 0
findViewById<TextView>(R.id.musicDetailMusicCurrentDuration)?.text = convertMusicDurationToText(currentMusicDuration)
findViewById<SeekBar>(R.id.musicDetailMusicCurrentDurationSeekBar).progress = currentMusicDuration
//音楽を再生する「サービス」に、次の曲へ進んで再生するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, NEXT_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//「ループ モード」ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailLoop).setOnClickListener { view ->
try {
loop = !loop
//音楽を再生する「サービス」に、ループ再生するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, LOOP_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
if (loop) {
findViewById<Button>(R.id.musicDetailLoop)?.setTextColor(resources.getColor(R.color.white, theme))
findViewById<Button>(R.id.musicDetailLoop)?.setBackgroundColor(resources.getColor(R.color.lime, theme))
} else {
findViewById<Button>(R.id.musicDetailLoop)?.setTextColor(resources.getColor(R.color.silver, theme))
findViewById<Button>(R.id.musicDetailLoop)?.setBackgroundColor(resources.getColor(R.color.gray, theme))
}
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//「ランダム モード」ボタンが押された時の処理
findViewById<Button>(R.id.musicDetailRandom).setOnClickListener { view ->
try {
random = !random
//音楽を再生する「サービス」に、ランダム再生するようにメッセージを送信
val bundle = Bundle()
bundle.putParcelableArrayList(MUSIC_INFO_LIST_KEY, musicInfoList)
bundle.putInt(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
bundle.putBoolean(LOOP_MUSIC_KEY, loop)
bundle.putBoolean(RANDOM_MUSIC_KEY, random)
bundle.putInt(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
val message: Message = Message.obtain(null, RANDOM_MUSIC_MESSAGE)
message.data = bundle
messenger?.send(message)
if (random) {
findViewById<Button>(R.id.musicDetailRandom)?.setTextColor(resources.getColor(R.color.white, theme))
findViewById<Button>(R.id.musicDetailRandom)?.setBackgroundColor(resources.getColor(R.color.lime, theme))
} else {
findViewById<Button>(R.id.musicDetailRandom)?.setTextColor(resources.getColor(R.color.silver, theme))
findViewById<Button>(R.id.musicDetailRandom)?.setBackgroundColor(resources.getColor(R.color.gray, theme))
}
} catch (exception: Exception) {
Toast.makeText(view.context.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//音楽を再生する「サービス」に、音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知をするようにメッセージを送信
val message: Message = Message.obtain(null, REQUEST_MUSIC_INFO_MESSAGE)
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
override fun onResume() {
try {
super.onResume()
//音楽を再生する「サービス」に、音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知をするようにメッセージを送信
val message: Message = Message.obtain(null, REQUEST_MUSIC_INFO_MESSAGE)
messenger?.send(message)
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//メモリーから破棄される時にのみ呼ばれます。
override fun onDestroy() {
try {
musicInfoList?.clear()
musicInfoList = null
unregisterReceiver(musicInfoUpdateBroadcastReceiver)
musicInfoUpdateBroadcastReceiver = null
unregisterReceiver(musicCurrentDurationUpdateBroadcastReceiver)
musicCurrentDurationUpdateBroadcastReceiver = null
messenger = null
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
} finally {
super.onDestroy()
}
}
}
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
eliphas1810/freesimplemusicplayerは著者が付けたJavaやKotlinのプログラムのパッケージのディレクトリの相対パスです。
eliphas1810.freesimplemusicplayerは著者が付けたJavaやKotlinのプログラムのパッケージの名前です。
⑰バック グラウンドなどで音楽を再生したりする「(フォア グラウンド )サービス」で、標準ライブラリーの、動画や音楽を再生してくれるMediaPlayerを呼びます。
※ちなみに、MediaPlayerよりも高機能なExoPlayerが追加ライブラリーの形で提供されています。
※ただし、ExoPlayerはバージョンのアップデートで、ExoPlayerを利用しているプログラムのコードの修正が必要に成る場合が有ります。
・アンドロイド システムでは、Bluetoothイヤホンなどの物理ボタンを「メディア ボタン」と呼びます。
・アンドロイド システムは、「メディア ボタン」の操作イベントを受け取る仕組みを「メディア セッション」という名前で提供してくれています。
・「オーディオ フォーカス(Audio Focus)」に対応する場合や、「メディア セッション」に対応する場合は、「(フォア グラウンド )サービス」はServiceクラスではなくMediaBrowserServiceCompatクラスを継承するようです。
※普通の「(フォア グラウンド )サービス」はServiceクラスを継承します。
※「フォア グラウンド サービス」はstartForegroundService(intent: Intent)から5秒以内にstartForeground(flag: Int, notification: Notification)を呼ぶ必要が有ります。
※通知のNotificationクラスは、通知チャンネルのNotificationChannelを作成する必要が有ります。
・アクティビティから「サービス」への情報を受け取る「ハンドラー」と呼ばれる物を用意して、MediaPlayerを呼びます。
・アンドロイド システムからの、有線イヤホンや無線イヤホンなどからスマホやタブレットの内蔵スピーカーへ音声出力先が戻った通知を受け取る物を用意して、事故で意図せず有線イヤホンが抜けた場合などに音楽の再生を一時停止します。
・再生中の音楽の再生終了を受け取る物として「(フォア グラウンド )サービス」自身をMediaPlayerに指定します。
・再生中の音楽が再生終了した場合に呼ばれる処理を用意して、その場合、次の曲へ進ませます。
・音楽再生中、画面がスリープされても、CPUがスリープされないようにMediaPlayerへ設定します。
・アンドロイド システムの音楽の音量の設定をMediaPlayerに適用します。
・電話アプリやYoutubeアプリといった他の音声を再生するアプリによって「オーディオ フォーカス(Audio Focus)」が失われた場合の処理を用意して、「オーディオ フォーカス」の判定処理で利用します。
・Bluetoothイヤホンなどの物理ボタンである「メディア ボタン」の操作イベントを受け取る「メディア セッション」の処理を用意して、アンドロイド システムに設定します。
・別スレッドを作成して、1秒おきに、音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知をします。
/home/◯◯◯/AndroidStudioProjects/FreeSimpleMusicPlayer/app/src/main/java/eliphas1810/freesimplemusicplayer/MusicPlayerService.kt
――――――――――――――――――――
package eliphas1810.freesimplemusicplayer
import android.app.*
import android.content.*
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.*
import android.provider.MediaStore
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.view.KeyEvent
import android.widget.Toast
import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.session.MediaButtonReceiver
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
//アクティビティからサービスへの送信情報を受け取る物
class MusicPlayerActivityHandler(
var musicPlayerService: MusicPlayerService?
) : Handler(Looper.getMainLooper()) {
override fun handleMessage(message: Message) {
try {
if (message.what == MusicPlayerService.REQUEST_MUSIC_INFO_MESSAGE) {
musicPlayerService?.updateMusicInfo()
return
}
val bundle = message.data
//アクティビティからサービスへの送信情報から、音楽ファイルの一覧の情報を受け取ります。
musicPlayerService?.musicInfoList = if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) { //アンドロイド13(ティラミス)以上の場合
bundle.getParcelableArrayList(MusicPlayerService.MUSIC_INFO_LIST_KEY, MusicInfo::class.java)
} else {
bundle.getParcelableArrayList(MusicPlayerService.MUSIC_INFO_LIST_KEY)
}
//アクティビティからサービスへの送信情報から、選択中のゼロから始まる音楽ファイルの番号を受け取ります。
musicPlayerService?.musicInfoIndex = bundle.getInt(MusicPlayerService.MUSIC_INFO_INDEX_KEY)
//アクティビティからサービスへの送信情報から、ループ再生するか否かを受け取ります。
musicPlayerService?.loop = bundle.getBoolean(MusicPlayerService.LOOP_MUSIC_KEY)
//アクティビティからサービスへの送信情報から、ランダム再生するか否かを受け取ります。
musicPlayerService?.random = bundle.getBoolean(MusicPlayerService.RANDOM_MUSIC_KEY)
//アクティビティからサービスへの送信情報から、音楽の再生開始時間を受け取ります。
musicPlayerService?.currentMusicDuration = bundle.getInt(MusicPlayerService.CURRENT_MUSIC_DURATION_KEY)
if (message.what == MusicPlayerService.START_MUSIC_MESSAGE) {
musicPlayerService?.start()
return
}
if (message.what == MusicPlayerService.PAUSE_MUSIC_MESSAGE) {
musicPlayerService?.pause()
return
}
if (message.what == MusicPlayerService.STOP_MUSIC_MESSAGE) {
musicPlayerService?.stop()
return
}
if (message.what == MusicPlayerService.PREVIOUS_MUSIC_MESSAGE) {
musicPlayerService?.previous()
return
}
if (message.what == MusicPlayerService.NEXT_MUSIC_MESSAGE) {
musicPlayerService?.next()
return
}
if (message.what == MusicPlayerService.SEEK_MUSIC_MESSAGE) {
musicPlayerService?.seek()
return
}
} catch (exception: Exception) {
Toast.makeText(musicPlayerService?.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
} finally {
super.handleMessage(message)
}
}
}
//事故で意図せず有線イヤホンが抜けた場合などに音楽の再生を一時停止する物
//
//有線イヤホンや無線イヤホンなどからスマホやタブレットの内蔵スピーカーへ音声出力先が戻る通知を受け取る物
//
//アンドロイド システムからの通知を受け取ります。
//
private class AudioBecomingNoisyBroadcastReceiver(
var musicPlayerService: MusicPlayerService?
) : BroadcastReceiver() {
//事故で意図せず有線イヤホンが抜けた場合などの処理
//
//有線イヤホンや無線イヤホンなどからスマホやタブレットの内蔵スピーカーへ音声出力先が戻る通知を受け取った場合の処理
//
//アンドロイド システムからの通知を受け取った場合の処理
//
override fun onReceive(context: Context?, intent: Intent?) {
try {
//音楽の再生を一時停止
musicPlayerService?.pause()
} catch (exception: Exception) {
Toast.makeText(musicPlayerService?.applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
}
//アンドロイド アプリのバックグラウンド処理の部分である「サービス」と呼ばれる物
//
//音楽を再生する「サービス」
//
//Bluetoothイヤホンの物理ボタンなどの「メディア ボタン」との接続である「メディア セッション」に対応する場合は、「サービス」はServiceクラスではなくMediaBrowserServiceCompatクラスを継承
//
class MusicPlayerService : MediaBrowserServiceCompat(), MediaPlayer.OnCompletionListener {
companion object {
//アクティビティから、音楽を再生したりする当サービスへ送信する情報の名前
//当サービスから、アクティビティへ通知する情報の名前
//
//重複を避けるため、「パッケージ名 + 情報の名前」
//
const val MUSIC_INFO_LIST_KEY = "eliphas1810.freesimplemusicplayer.MUSIC_INFO_LIST"
const val MUSIC_INFO_INDEX_KEY = "eliphas1810.freesimplemusicplayer.MUSIC_INFO_INDEX"
const val LOOP_MUSIC_KEY = "eliphas1810.freesimplemusicplayer.LOOP_MUSIC"
const val RANDOM_MUSIC_KEY = "eliphas1810.freesimplemusicplayer.RANDOM_MUSIC"
const val CURRENT_MUSIC_DURATION_KEY = "eliphas1810.freesimplemusicplayer.CURRENT_MUSIC_DURATION"
//アクティビティから、音楽を再生したりする当サービスへの送信情報のコードは、アンドロイド アプリ開発者の責任で重複させない事
const val START_MUSIC_MESSAGE = 1
const val PAUSE_MUSIC_MESSAGE = 2
const val STOP_MUSIC_MESSAGE = 3
const val PREVIOUS_MUSIC_MESSAGE = 4
const val NEXT_MUSIC_MESSAGE = 5
const val SEEK_MUSIC_MESSAGE = 6
//const val LOOP_MUSIC_MESSAGE = 7
//const val RANDOM_MUSIC_MESSAGE = 8
const val REQUEST_MUSIC_INFO_MESSAGE = 9
//音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知の名前
const val UPDATE_MUSIC_INFO_KEY = "eliphas1810.freesimplemusicplayer.UPDATE_MUSIC_INFO"
//音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知の名前
const val UPDATE_MUSIC_CURRENT_DURATION_KEY = "eliphas1810.freesimplemusicplayer.UPDATE_MUSIC_CURRENT_DURATION"
//メディア セッションで、当サービスへの接続は許可するが、何も情報を返さない場合のメディア ルートID。
//
//当サービス内だけの、アプリ開発者独自の値
//
private const val EMPTY_MEDIA_ROOT_ID = "eliphas1810.freesimplemusicplayer.EMPTY_MEDIA_ROOT_ID"
private const val CHANNEL_ID = "eliphas1810.freesimplemusicplayer.CHANNEL_ID"
}
private var messenger: Messenger? = null
private var mediaPlayer: MediaPlayer? = null
var musicInfoList: ArrayList<MusicInfo>? = null
var musicInfoIndex = 0
var loop = false
var random = false
var currentMusicDuration = 0
var starting = false
var pausing = false
var stopping = false
//当アプリ以外によるファイルの取得先
var externalContentUri: Uri? = null
private var audioBecomingNoisyBroadcastReceiver: AudioBecomingNoisyBroadcastReceiver? = null
var audioManager: AudioManager? = null
var audioFocusRequestCompat: AudioFocusRequestCompat? = null
var mediaSessionCompat: MediaSessionCompat? = null
var scheduledExecutorService: ScheduledExecutorService? = null
//音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知をします。
fun updateMusicInfo() {
val intent = Intent(UPDATE_MUSIC_INFO_KEY)
intent.putParcelableArrayListExtra(MUSIC_INFO_LIST_KEY, musicInfoList)
intent.putExtra(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
intent.putExtra(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
intent.putExtra(LOOP_MUSIC_KEY, loop)
intent.putExtra(RANDOM_MUSIC_KEY, random)
baseContext.sendBroadcast(intent)
}
//音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知をします。
fun updateMusicCuurentDuration() {
currentMusicDuration = mediaPlayer?.currentPosition ?: 0
val intent = Intent(UPDATE_MUSIC_CURRENT_DURATION_KEY)
intent.putParcelableArrayListExtra(MUSIC_INFO_LIST_KEY, musicInfoList)
intent.putExtra(MUSIC_INFO_INDEX_KEY, musicInfoIndex)
intent.putExtra(CURRENT_MUSIC_DURATION_KEY, currentMusicDuration)
baseContext.sendBroadcast(intent)
}
//音楽を再生
fun start() {
//オーディオ フォーカス(Audio Focus)を得られない場合
if (AudioManagerCompat.requestAudioFocus(audioManager!!, audioFocusRequestCompat!!) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
//音楽を再生せず終了
return
}
val musicInfo = musicInfoList?.get(musicInfoIndex)
val mediaStoreId = musicInfo?.id
val uri = ContentUris.withAppendedId(externalContentUri!!, mediaStoreId!!)
if (starting) {
mediaPlayer?.stop()
stopping = true
starting = false
pausing = false
currentMusicDuration = 0
mediaPlayer?.reset()
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else if (pausing) {
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else if (stopping) {
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
stopping = false
pausing = false
} else {
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
}
}
//音楽の再生を一時停止
fun pause() {
if (starting) {
mediaPlayer?.pause()
pausing = true
starting = false
stopping = false
}
}
//音楽の再生を停止
fun stop() {
//得ていたオーディオ フォーカス(Audio Focus)を放棄
AudioManagerCompat.abandonAudioFocusRequest(audioManager!!, audioFocusRequestCompat!!)
if (starting || pausing) {
mediaPlayer?.stop()
stopping = true
starting = false
pausing = false
currentMusicDuration = 0
}
}
//前の曲へ戻って再生
fun previous() {
if (loop == false) {
val musicInfoCount = musicInfoList?.size ?: 0
if (random) {
musicInfoIndex = (0..(musicInfoCount - 1)).random()
} else {
musicInfoIndex = musicInfoIndex - 1
if (musicInfoIndex <= -1) {
musicInfoIndex = musicInfoCount - 1
}
}
}
//音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知をします。
updateMusicInfo()
//オーディオ フォーカス(Audio Focus)を得られない場合
if (AudioManagerCompat.requestAudioFocus(audioManager!!, audioFocusRequestCompat!!) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
//音楽を再生せず終了
return
}
val musicInfo = musicInfoList?.get(musicInfoIndex)
val mediaStoreId = musicInfo?.id
val uri = ContentUris.withAppendedId(externalContentUri!!, mediaStoreId!!)
if (starting || pausing) {
mediaPlayer?.stop()
stopping = true
starting = false
pausing = false
currentMusicDuration = 0
mediaPlayer?.reset()
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else if (stopping) {
mediaPlayer?.reset()
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else {
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
}
}
//次の曲へ進んで再生
fun next() {
if (loop == false) {
val musicInfoCount = musicInfoList?.size ?: 0
if (random) {
musicInfoIndex = (0..(musicInfoCount - 1)).random()
} else {
musicInfoIndex = musicInfoIndex + 1
if (musicInfoCount <= musicInfoIndex) {
musicInfoIndex = 0
}
}
}
//音楽ファイルの詳細画面に表示する音楽ファイルの情報の更新を促す通知をします。
updateMusicInfo()
//オーディオ フォーカス(Audio Focus)を得られない場合
if (AudioManagerCompat.requestAudioFocus(audioManager!!, audioFocusRequestCompat!!) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
//音楽を再生せず終了
return
}
val musicInfo = musicInfoList?.get(musicInfoIndex)
val mediaStoreId = musicInfo?.id
val uri = ContentUris.withAppendedId(externalContentUri!!, mediaStoreId!!)
if (starting || pausing) {
mediaPlayer?.stop()
stopping = true
starting = false
pausing = false
currentMusicDuration = 0
mediaPlayer?.reset()
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else if (stopping) {
mediaPlayer?.reset()
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else {
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
}
}
//音楽の再生開始時間を指定して再生
fun seek() {
//オーディオ フォーカス(Audio Focus)を得られない場合
if (AudioManagerCompat.requestAudioFocus(audioManager!!, audioFocusRequestCompat!!) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
//音楽を再生せず終了
return
}
val musicInfo = musicInfoList?.get(musicInfoIndex)
val mediaStoreId = musicInfo?.id
val uri = ContentUris.withAppendedId(externalContentUri!!, mediaStoreId!!)
if (starting) {
mediaPlayer?.stop()
stopping = true
starting = false
pausing = false
mediaPlayer?.reset()
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.seekTo(currentMusicDuration)
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else if (pausing) {
mediaPlayer?.seekTo(currentMusicDuration)
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
} else if (stopping) {
mediaPlayer?.prepare()
mediaPlayer?.seekTo(currentMusicDuration)
mediaPlayer?.start()
starting = true
stopping = false
pausing = false
} else {
mediaPlayer?.setDataSource(this, uri)
mediaPlayer?.prepare()
mediaPlayer?.seekTo(currentMusicDuration)
mediaPlayer?.start()
starting = true
pausing = false
stopping = false
}
}
//再生中の音楽が再生終了した場合を処理
override fun onCompletion(mediaPlayer: MediaPlayer) {
try {
//次の音楽を再生
next()
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//メモリー上に作成される時にのみ呼ばれます。
override fun onCreate() {
try {
super.onCreate()
//アンドロイド8(オレオ)以上の場合
if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notificationChannel = NotificationChannel(CHANNEL_ID, getText(R.string.notification_content_title), NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(notificationChannel)
val notificationBuilder = Notification.Builder(applicationContext, CHANNEL_ID)
notificationBuilder.setContentTitle(getText(R.string.notification_content_title))
notificationBuilder.setContentText(getText(R.string.notification_content_text))
notificationBuilder.setTicker(getText(R.string.notification_ticker))
val notification = notificationBuilder.build()
//startForeground()を実行しないと、アンドロイド システムに強制終了されてしまいます。
//
//startForeground()には、通知(Notification)が必要です。
//
startForeground(1, notification)
}
mediaPlayer = MediaPlayer()
if (musicInfoList != null && 1 <= (musicInfoList?.size ?: 0)) {
musicInfoList?.clear()
}
musicInfoList = ArrayList(listOf())
//当アプリ以外によるファイルの取得先
externalContentUri =
if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) { //アンドロイド10(Q)以上の場合
MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
} else {
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
//再生中の音楽の再生終了を受け取る物を設定
mediaPlayer?.setOnCompletionListener(this)
//音楽再生中、画面がスリープされても、CPUがスリープされないようにします。
mediaPlayer?.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK)
//アンドロイド システムの音楽の音量の設定を適用
mediaPlayer?.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
//事故で意図せず有線イヤホンが抜けた場合などの通知を受け取る物を設定
//
//有線イヤホンや無線イヤホンなどからスマホやタブレットの内蔵スピーカーへ音声出力先が戻る通知を受け取る物を設定
//
audioBecomingNoisyBroadcastReceiver = AudioBecomingNoisyBroadcastReceiver(this)
registerReceiver(audioBecomingNoisyBroadcastReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
//電話アプリやYoutubeアプリといった他の音声を再生するアプリによってオーディオ フォーカス(Audio Focus)が失われた場合の処理
audioManager = getSystemService(AudioManager::class.java)
val audioAttributesCompatBuilder = AudioAttributesCompat.Builder()
audioAttributesCompatBuilder.setUsage(AudioAttributesCompat.USAGE_MEDIA)
audioAttributesCompatBuilder.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC)
val audioFocusRequestCompatBuilder = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN)
audioFocusRequestCompatBuilder.setAudioAttributes(audioAttributesCompatBuilder.build())
audioFocusRequestCompatBuilder.setOnAudioFocusChangeListener { focusChange ->
//電話が、かかってきて、通話が終了した時などの場合
//
//オーディオ フォーカス(Audio Focus)が戻ってきた場合
if (focusChange == AudioManager.AUDIOFOCUS_GAIN) {
//音楽の再生を再開
start()
//電話が、かかってきている時や、通話中などの場合
//
//オーディオ フォーカス(Audio Focus)が一時的に失われた場合
} else if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
//音楽の再生を一時停止
pause()
//Youtubeアプリなどの音声を再生するアプリを起動した場合
//
//オーディオ フォーカス(Audio Focus)が失われた場合
} else if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
//音楽の再生を停止
stop()
}
}
audioFocusRequestCompat = audioFocusRequestCompatBuilder.build()
//Bluetoothイヤホンの物理ボタンなどの「メディア ボタン」との接続である「メディア セッション」の処理
mediaSessionCompat = MediaSessionCompat(
this,
MusicPlayerService::class.java.name
)
val playbackStateCompatBuilder = PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_PLAY_PAUSE
or PlaybackStateCompat.ACTION_STOP
or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
mediaSessionCompat?.setPlaybackState(playbackStateCompatBuilder?.build())
mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() {
override fun onMediaButtonEvent(intent: Intent): Boolean {
var keyEvent = if (Build.VERSION_CODES.TIRAMISU <= Build.VERSION.SDK_INT) { //アンドロイド13(ティラミス)以上の場合
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
}
if (keyEvent == null) {
return false
}
//ACTION_DOWNとACTION_UPの二重でonMediaButtonEventが呼ばれるので、ACTION_DOWNの場合だけ処理して、処理の重複を回避
if (keyEvent.action != KeyEvent.ACTION_DOWN) {
return false
}
if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_PLAY) {
start()
return true
}
if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) {
pause()
return true
}
if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) { //再生と一時停止が同一の物理ボタンの場合
if (starting) {
pause()
return true
}
if (pausing || stopping) {
start()
}
return true
}
if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_STOP) {
stop()
return true
}
if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_PREVIOUS) {
previous()
return true
}
if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_NEXT) {
next()
return true
}
return super.onMediaButtonEvent(intent)
}
})
sessionToken = mediaSessionCompat?.sessionToken
mediaSessionCompat?.isActive = true
MediaButtonReceiver.handleIntent(
mediaSessionCompat,
Intent(
applicationContext,
MusicPlayerService::class.java
)
)
//1秒おきに、音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知をします。
scheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
scheduledExecutorService?.scheduleAtFixedRate(
{
try {
if (starting) {
//音楽ファイルの詳細画面に表示する現在の再生時間の更新を促す通知をします。
updateMusicCuurentDuration()
}
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
},
1, //1回目までの時間間隔の時間数
1, //1回目以降の時間間隔の時間数
TimeUnit.SECONDS //時間の単位。秒。
)
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
}
}
//「サービス」が起動される時に呼ばれます。
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
} finally {
return START_NOT_STICKY
}
}
//「サービス」がバインドされる時に呼ばれます。
override fun onBind(intent: Intent): IBinder? {
try {
messenger = Messenger(MusicPlayerActivityHandler(this))
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
} finally {
return messenger?.binder
}
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
return BrowserRoot(EMPTY_MEDIA_ROOT_ID, null)
}
override fun onLoadChildren(
parentMediaId: String,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
result.sendResult(null) //メディア セッションで、当サービスへの接続は許可するが、何も情報を返しません。
return
}
//メモリーから破棄される時にのみ呼ばれます。
override fun onDestroy() {
try {
scheduledExecutorService?.shutdownNow()
scheduledExecutorService = null
messenger = null
if (mediaPlayer?.isPlaying() ?: false) {
mediaPlayer?.stop()
}
mediaPlayer?.reset()
mediaPlayer?.release()
mediaPlayer = null
musicInfoList?.clear()
musicInfoList = null
externalContentUri = null
unregisterReceiver(audioBecomingNoisyBroadcastReceiver)
audioBecomingNoisyBroadcastReceiver?.musicPlayerService = null
audioBecomingNoisyBroadcastReceiver = null
AudioManagerCompat.abandonAudioFocusRequest(audioManager!!, audioFocusRequestCompat!!)
audioManager = null
audioFocusRequestCompat = null
mediaSessionCompat?.release()
mediaSessionCompat = null
} catch (exception: Exception) {
Toast.makeText(applicationContext, exception.toString(), Toast.LENGTH_LONG).show()
throw exception
} finally {
super.onDestroy()
}
}
}
――――――――――――――――――――
◯◯◯はLinux Mintのユーザー名です。
FreeSimpleMusicPlayerは著者が付けたAndroid Studioのプロジェクトの名前です。
eliphas1810/freesimplemusicplayerは著者が付けたJavaやKotlinのプログラムのパッケージのディレクトリの相対パスです。
eliphas1810.freesimplemusicplayerは著者が付けたJavaやKotlinのプログラムのパッケージの名前です。
新規登録で充実の読書を
- マイページ
- 読書の状況から作品を自動で分類して簡単に管理できる
- 小説の未読話数がひと目でわかり前回の続きから読める
- フォローしたユーザーの活動を追える
- 通知
- 小説の更新や作者の新作の情報を受け取れる
- 閲覧履歴
- 以前読んだ小説が一覧で見つけやすい
アカウントをお持ちの方はログイン
ビューワー設定
文字サイズ
背景色
フォント
組み方向
機能をオンにすると、画面の下部をタップする度に自動的にスクロールして読み進められます。
応援すると応援コメントも書けます