├── app ├── .gitignore ├── debug │ └── app-debug.aab ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── drawable │ │ │ │ ├── background_music.webp │ │ │ │ ├── ripple_transparent.xml │ │ │ │ ├── oval_black.xml │ │ │ │ ├── ic_cross.xml │ │ │ │ ├── ic_play.xml │ │ │ │ ├── ic_pause.xml │ │ │ │ ├── ic_search.xml │ │ │ │ ├── ic_download.xml │ │ │ │ ├── selector_play_pause.xml │ │ │ │ ├── ic_love.xml │ │ │ │ ├── ic_music.xml │ │ │ │ ├── ic_repeat.xml │ │ │ │ ├── ic_info.xml │ │ │ │ ├── ic_next.xml │ │ │ │ ├── ic_disc.xml │ │ │ │ ├── ic_random.xml │ │ │ │ ├── ic_loader.xml │ │ │ │ ├── avd_play_to_pause.xml │ │ │ │ ├── avd_pause_to_play.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── dimens.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── styles.xml │ │ │ │ └── strings.xml │ │ │ ├── anim │ │ │ │ ├── zoom_in.xml │ │ │ │ ├── rotation_wheel.xml │ │ │ │ ├── fade_in_from_bottom.xml │ │ │ │ ├── slide_from_left.xml │ │ │ │ └── slide_to_right.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ │ └── colors.xml │ │ │ ├── values-zh-rTW │ │ │ │ └── strings.xml │ │ │ ├── layout │ │ │ │ ├── dialog_loading.xml │ │ │ │ ├── notification_small.xml │ │ │ │ ├── notification_large.xml │ │ │ │ ├── adapter_song_list.xml │ │ │ │ ├── activity_song_list.xml │ │ │ │ └── activity_play_song.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── a1573595 │ │ │ │ └── musicplayer │ │ │ │ ├── BasePresenter.kt │ │ │ │ ├── model │ │ │ │ ├── TimeUtil.kt │ │ │ │ └── Song.kt │ │ │ │ ├── BaseView.kt │ │ │ │ ├── PlayerApplication.kt │ │ │ │ ├── songList │ │ │ │ ├── SongListView.kt │ │ │ │ ├── SongListAdapter.kt │ │ │ │ ├── SongListPresenter.kt │ │ │ │ └── SongListActivity.kt │ │ │ │ ├── playSong │ │ │ │ ├── PlaySongView.kt │ │ │ │ ├── PlaySongPresenter.kt │ │ │ │ └── PlaySongActivity.kt │ │ │ │ ├── Weak.kt │ │ │ │ ├── player │ │ │ │ ├── AudioObserver.kt │ │ │ │ ├── PlayerManager.kt │ │ │ │ └── PlayerService.kt │ │ │ │ ├── customView │ │ │ │ ├── BezierEvaluator.kt │ │ │ │ └── FloatingAnimationView.kt │ │ │ │ ├── BaseActivity.kt │ │ │ │ └── BaseSongActivity.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── a1573595 │ │ │ └── musicplayer │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── a1573595 │ │ └── musicplayer │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── compiler.xml ├── kotlinc.xml ├── vcs.xml ├── migrations.xml ├── deploymentTargetDropDown.xml ├── gradle.xml ├── jarRepositories.xml └── misc.xml ├── .gitignore ├── settings.gradle ├── gradle.properties ├── LICENSE ├── README.zh-tw.md ├── README.md ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/debug/app-debug.aab: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/debug/app-debug.aab -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/background_music.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/drawable/background_music.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/BasePresenter.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | abstract class BasePresenter constructor(val view: V) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a1573595/MusicPlayer/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ripple_transparent.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/oval_black.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Jan 10 09:58:09 CST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | /app/release 16 | /.idea/sonarlint -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/anim/zoom_in.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/anim/rotation_wheel.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/model/TimeUtil.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.model 2 | 3 | object TimeUtil { 4 | fun timeMillisToTime(duration: Long): String { 5 | val minutes = duration / 60000 6 | val seconds = duration % 60000 / 1000 7 | return String.format("%02d:%02d", minutes, seconds) 8 | } 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/BaseView.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import android.content.Context 4 | import android.view.View 5 | 6 | interface BaseView { 7 | fun isActive(): Boolean 8 | 9 | fun context(): Context 10 | 11 | fun showToast(msg: String) 12 | 13 | fun showSnackBar(v: View, msg: String) 14 | } -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14dp 4 | 18dp 5 | 22dp 6 | 32dp 7 | 48dp 8 | 64dp 9 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/PlayerApplication.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import android.app.Application 4 | import timber.log.Timber 5 | 6 | class PlayerApplication: Application() { 7 | override fun onCreate() { 8 | super.onCreate() 9 | 10 | if (BuildConfig.DEBUG) { 11 | Timber.plant(Timber.DebugTree()) 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/songList/SongListView.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.songList 2 | 3 | import com.a1573595.musicplayer.BaseView 4 | import com.a1573595.musicplayer.model.Song 5 | 6 | interface SongListView : BaseView { 7 | fun showLoading() 8 | 9 | fun stopLoading() 10 | 11 | fun updateSongState(song: Song, isPlaying: Boolean) 12 | 13 | fun onSongClick() 14 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | rootProject.name='MusicPlayer' 16 | include ':app' 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/playSong/PlaySongView.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.playSong 2 | 3 | import com.a1573595.musicplayer.BaseView 4 | import com.a1573595.musicplayer.model.Song 5 | 6 | interface PlaySongView : BaseView { 7 | fun updateSongState(song: Song, isPlaying: Boolean, progress: Int) 8 | 9 | fun showRepeat(isRepeat: Boolean) 10 | 11 | fun showRandom(isRandom: Boolean) 12 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #02c0a6 4 | #008577 5 | #D81B60 6 | 7 | #ffffff 8 | @android:color/white 9 | @android:color/black 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | #EB292929 8 | @android:color/white 9 | @android:color/white 10 | 11 | -------------------------------------------------------------------------------- /app/src/test/java/com/a1573595/musicplayer/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cross.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/anim/fade_in_from_bottom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 14 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_from_left.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 14 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_to_right.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 14 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/model/Song.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.model 2 | 3 | import androidx.recyclerview.widget.DiffUtil 4 | 5 | data class Song( 6 | val id: String, 7 | val name: String, 8 | val author: String, 9 | val duration: Long 10 | ) 11 | 12 | class SongItemCallback : DiffUtil.ItemCallback() { 13 | override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean { 14 | return oldItem.id == newItem.id 15 | } 16 | 17 | override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean { 18 | return oldItem.name == newItem.name 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pause.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/Weak.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import timber.log.Timber 4 | import java.lang.ref.WeakReference 5 | import kotlin.reflect.KProperty 6 | 7 | class Weak(initializer: () -> T?) { 8 | var weakReference = WeakReference(initializer()) 9 | 10 | constructor() : this({ null }) 11 | 12 | operator fun getValue(thisRef: Any?, property: KProperty<*>): T? { 13 | Timber.d("Weak Delegate getValue") 14 | return weakReference.get() 15 | } 16 | 17 | operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T?) { 18 | Timber.d("Weak Delegate setValue") 19 | weakReference = WeakReference(value) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/player/AudioObserver.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.player 2 | 3 | import android.database.ContentObserver 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import android.os.Handler 7 | import android.os.Message 8 | 9 | class AudioObserver(private val handler: Handler) : ContentObserver(handler) { 10 | override fun onChange(selfChange: Boolean, uri: Uri?) { 11 | super.onChange(selfChange, uri) 12 | 13 | if (selfChange) return 14 | 15 | uri?.lastPathSegment?.let { 16 | val b = Bundle() 17 | b.putString("songID", it) 18 | 19 | val msg = Message() 20 | msg.data = b 21 | 22 | handler.sendMessage(msg) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rTW/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 同意 4 | 取消 5 | 不同意 6 | 下載 7 | 下載音樂 8 | 找到新歌曲 9 | 需要權限存取音樂 10 | 未找到歌曲 11 | 權限需求 12 | 搜尋音樂 13 | \u003c未知\u003e 14 | 不支援的格式 15 | 再按一次退出 16 | 無法開啟瀏覽器 17 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/a1573595/musicplayer/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.a1573595.musicplayer", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_download.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/customView/BezierEvaluator.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.customView 2 | 3 | import android.animation.TypeEvaluator 4 | import android.graphics.Point 5 | 6 | class BezierEvaluator(private val controlPoint1: Point, private val controlPoint2: Point) : TypeEvaluator { 7 | override fun evaluate(t: Float, startValue: Point, endValue: Point): Point { 8 | val x = startValue.x * (1 - t) * (1 - t) * (1 - t) + (3f 9 | * controlPoint1.x * t * (1 - t) * (1 - t)) + (3f 10 | * controlPoint2.x * (1 - t) * t * t) + endValue.x * t * t * 11 | t 12 | val y = startValue.y * (1 - t) * (1 - t) * (1 - t) + (3f 13 | * controlPoint1.y * t * (1 - t) * (1 - t)) + (3f 14 | * controlPoint2.y * (1 - t) * t * t) + endValue.y * t * t * 15 | t 16 | return Point(x.toInt(), y.toInt()) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/selector_play_pause.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 14 | 15 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_love.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_music.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_repeat.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_info.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_next.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/layout/dialog_loading.xml: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 26 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 18 | 19 | 22 | 23 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/layout/notification_small.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 17 | 18 | 32 | 33 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/playSong/PlaySongPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.playSong 2 | 3 | import com.a1573595.musicplayer.BasePresenter 4 | import com.a1573595.musicplayer.player.PlayerService 5 | 6 | class PlaySongPresenter(view: PlaySongView) : BasePresenter(view) { 7 | private lateinit var player: PlayerService 8 | 9 | fun setPlayerManager(player: PlayerService) { 10 | this.player = player 11 | 12 | fetchSongState() 13 | } 14 | 15 | fun fetchSongState() { 16 | player.getSong()?.let { 17 | view.updateSongState(it, player.isPlaying(), player.getProgress()) 18 | 19 | view.showRepeat(player.isRepeat) 20 | view.showRandom(player.isRandom) 21 | } 22 | } 23 | 24 | fun updateRepeat(): Boolean { 25 | player.isRepeat = !player.isRepeat 26 | return player.isRepeat 27 | } 28 | 29 | fun updateRandom(): Boolean { 30 | player.isRandom = !player.isRandom 31 | return player.isRandom 32 | } 33 | 34 | fun onSongPlay() { 35 | if (!player.isPlaying()) { 36 | player.play() 37 | } else { 38 | player.pause() 39 | } 40 | } 41 | 42 | fun skipToNext() = player.skipToNext() 43 | 44 | fun skipToPrevious() = player.skipToPrevious() 45 | 46 | fun seekTo(duration: Int) = player.seekTo(duration) 47 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_disc.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=false 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | android.defaults.buildfeatures.buildconfig=true 23 | android.nonTransitiveRClass=true 24 | android.nonFinalResIds=false 25 | org.gradle.configuration-cache=true 26 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | MusicPlayer 3 | 4 | 7 | true 8 | 9 | Agree 10 | Cancel 11 | Disagree 12 | Download 13 | Download music 14 | Fond new song 15 | Need permission to access music. 16 | No song found 17 | Permission requirement 18 | Search song 19 | \u003c unknown \u003e 20 | Unsupported format 21 | Press again to exit 22 | Can\'t open browser. 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/songList/SongListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.songList 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.ListAdapter 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.a1573595.musicplayer.databinding.AdapterSongListBinding 8 | import com.a1573595.musicplayer.model.Song 9 | import com.a1573595.musicplayer.model.SongItemCallback 10 | import com.a1573595.musicplayer.model.TimeUtil 11 | 12 | class SongListAdapter(private val presenter: SongListPresenter) : 13 | ListAdapter(SongItemCallback()) { 14 | inner class SongHolder(val viewBinding: AdapterSongListBinding) : 15 | RecyclerView.ViewHolder(viewBinding.root) { 16 | init { 17 | itemView.setOnClickListener { 18 | presenter.onSongClick(adapterPosition) 19 | } 20 | } 21 | } 22 | 23 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongHolder { 24 | val viewBinding = 25 | AdapterSongListBinding.inflate(LayoutInflater.from(parent.context), parent, false) 26 | return SongHolder(viewBinding) 27 | } 28 | 29 | override fun onBindViewHolder(holder: SongHolder, position: Int) { 30 | val song: Song = getItem(position) 31 | 32 | holder.viewBinding.tvName.text = song.name 33 | holder.viewBinding.tvArtist.text = song.author 34 | holder.viewBinding.tvDuration.text = TimeUtil.timeMillisToTime(song.duration) 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Chien 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_random.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import android.content.Context 4 | import android.content.pm.PackageManager.PERMISSION_GRANTED 5 | import android.os.Bundle 6 | import android.view.View 7 | import android.widget.Toast 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.core.app.ActivityCompat 10 | import androidx.core.content.ContextCompat 11 | import androidx.lifecycle.Lifecycle 12 | import com.google.android.material.snackbar.Snackbar 13 | 14 | abstract class BaseActivity

> : AppCompatActivity(), BaseView { 15 | protected lateinit var presenter: P 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | presenter = createPresenter() 20 | } 21 | 22 | override fun isActive(): Boolean = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) 23 | 24 | override fun context(): Context = this 25 | 26 | override fun showToast(msg: String) { 27 | Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() 28 | } 29 | 30 | override fun showSnackBar(v: View, msg: String) { 31 | Snackbar.make(v, msg, Snackbar.LENGTH_SHORT).show() 32 | } 33 | 34 | protected fun hasPermission(permission: String): Boolean = 35 | ContextCompat.checkSelfPermission(this, permission) == PERMISSION_GRANTED 36 | 37 | protected fun requestPermission(requestCode: Int, vararg permissions: String) { 38 | ActivityCompat.requestPermissions(this, permissions, requestCode) 39 | } 40 | 41 | protected abstract fun createPresenter(): P 42 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_loader.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("org.jetbrains.kotlin.android") 4 | } 5 | 6 | android { 7 | namespace = "com.a1573595.musicplayer" 8 | compileSdk = 34 9 | 10 | defaultConfig { 11 | applicationId = "com.a1573595.musicplayer" 12 | minSdk = 22 13 | targetSdk = 34 14 | versionCode = 14 15 | versionName = "1.4.0" 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | vectorDrawables { 19 | useSupportLibrary = true 20 | } 21 | } 22 | 23 | buildTypes { 24 | release { 25 | debuggable false 26 | minifyEnabled true 27 | shrinkResources true 28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 29 | } 30 | } 31 | 32 | compileOptions { 33 | sourceCompatibility = JavaVersion.VERSION_1_8 34 | targetCompatibility = JavaVersion.VERSION_1_8 35 | } 36 | kotlinOptions { 37 | jvmTarget = '1.8' 38 | } 39 | 40 | buildFeatures { 41 | viewBinding = true 42 | } 43 | } 44 | 45 | dependencies { 46 | implementation("androidx.core:core-ktx:1.12.0") 47 | implementation("androidx.appcompat:appcompat:1.6.1") 48 | implementation("com.google.android.material:material:1.11.0") 49 | implementation("androidx.constraintlayout:constraintlayout:2.1.4") 50 | 51 | implementation('androidx.lifecycle:lifecycle-runtime-ktx:2.7.0') 52 | 53 | implementation("com.jakewharton.timber:timber:5.0.1") 54 | debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13") 55 | 56 | testImplementation("junit:junit:4.13.2") 57 | androidTestImplementation("androidx.test.ext:junit:1.1.5") 58 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") 59 | } -------------------------------------------------------------------------------- /README.zh-tw.md: -------------------------------------------------------------------------------- 1 | *其他語言版本: [English](README.md), [中文](README.zh-tw.md).* 2 | 3 | # MusicPlayer 4 | Android音樂撥放器範例。 5 | 6 | Get it on Google Play 7 | 8 | ### 螢幕截圖 9 |

10 | 11 | 12 | 13 | 14 |
15 | 16 | 暗黑模式。 17 |
18 | 19 | 20 | 21 | 22 |
23 | 24 | ### 支援Android版本 25 | - Android 5.1 Lollipop(API level 21)或更高。 26 | 27 | ### 前置準備 28 | 下載 [範例音樂](https://ccrma.stanford.edu/~jos/pasp/Sound_Examples.html)至您的裝置中. 29 | 30 | ### 使用函示庫 31 | 1. [Material](https://material.io/) 32 | 2. [Coroutine](https://github.com/Kotlin/kotlinx.coroutines) 33 | 3. [LifecycleCoroutine](https://developer.android.com/topic/libraries/architecture/coroutines) 34 | 4. [ViewBinding](https://developer.android.com/topic/libraries/view-binding) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | *Read this in other languages: [English](README.md), [中文](README.zh-tw.md).* 2 | 3 | # MusicPlayer 4 | Android music player example. 5 | 6 | Get it on Google Play 7 | 8 | ### Screenshots 9 |
10 | 11 | 12 | 13 | 14 |
15 | 16 | Dark Mode. 17 |
18 | 19 | 20 | 21 | 22 |
23 | 24 | ### Supported Android Versions 25 | - Android 5.1 Lollipop(API level 21) or higher. 26 | 27 | ### Prepare 28 | Download [example media](https://ccrma.stanford.edu/~jos/pasp/Sound_Examples.html) to your device. 29 | 30 | ### Used libraries 31 | 1. [Material](https://material.io/) 32 | 2. [Coroutine](https://github.com/Kotlin/kotlinx.coroutines) 33 | 3. [LifecycleCoroutine](https://developer.android.com/topic/libraries/architecture/coroutines) 34 | 4. [ViewBinding](https://developer.android.com/topic/libraries/view-binding) -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 26 | 27 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/customView/FloatingAnimationView.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.customView 2 | 3 | import android.animation.Animator 4 | import android.animation.AnimatorListenerAdapter 5 | import android.animation.ValueAnimator 6 | import android.content.Context 7 | import android.content.res.Resources 8 | import android.graphics.Point 9 | import android.util.AttributeSet 10 | import android.view.ViewGroup 11 | import android.view.animation.AccelerateDecelerateInterpolator 12 | import java.util.* 13 | 14 | class FloatingAnimationView constructor( 15 | context: Context, 16 | attrs: AttributeSet? = null, 17 | defStyleAttr: Int = 0 18 | ) : androidx.appcompat.widget.AppCompatImageView(context, attrs, defStyleAttr) { 19 | lateinit var startPosition: Point 20 | lateinit var endPosition: Point 21 | 22 | private val random = Random() 23 | 24 | fun startAnimation() { 25 | val width = getScreenWidth() 26 | val height = getScreenHeight() 27 | 28 | val endPointRandom = 29 | Point(random.nextInt(width), endPosition.y) 30 | 31 | val bezierTypeEvaluator = BezierEvaluator( 32 | Point(random.nextInt(width), random.nextInt(height)), 33 | Point(random.nextInt(width / 2), random.nextInt(height / 2)) 34 | ) 35 | 36 | val animator = ValueAnimator.ofObject(bezierTypeEvaluator, startPosition, endPointRandom) 37 | 38 | animator.addUpdateListener { valueAnimator -> 39 | val point = valueAnimator.animatedValue as Point 40 | val fraction = valueAnimator.animatedFraction 41 | 42 | x = point.x.toFloat() 43 | y = point.y.toFloat() 44 | alpha = 1 - fraction 45 | 46 | invalidate() 47 | } 48 | 49 | animator.addListener(object : AnimatorListenerAdapter() { 50 | override fun onAnimationEnd(animation: Animator) { 51 | super.onAnimationEnd(animation) 52 | 53 | val viewGroup = parent as ViewGroup 54 | viewGroup.removeView(this@FloatingAnimationView) 55 | } 56 | }) 57 | 58 | animator.duration = 2000 59 | animator.interpolator = AccelerateDecelerateInterpolator() 60 | animator.start() 61 | } 62 | 63 | private fun getScreenWidth(): Int { 64 | return Resources.getSystem().displayMetrics.widthPixels 65 | } 66 | 67 | private fun getScreenHeight(): Int { 68 | return Resources.getSystem().displayMetrics.heightPixels 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/player/PlayerManager.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.player 2 | 3 | import android.media.MediaPlayer 4 | import timber.log.Timber 5 | import java.beans.PropertyChangeSupport 6 | import java.io.FileDescriptor 7 | 8 | class PlayerManager : PropertyChangeSupport(this) { 9 | companion object { 10 | const val ACTION_COMPLETE = "action.COMPLETE" 11 | const val ACTION_PLAY = "action.PLAY" 12 | const val ACTION_PAUSE = "action.PAUSE" 13 | const val ACTION_STOP = "action.STOP" 14 | } 15 | 16 | private val mediaPlayer: MediaPlayer = MediaPlayer() 17 | 18 | var playerProgress: Int = 0 19 | get() { 20 | return if (mediaPlayer.isPlaying) { 21 | mediaPlayer.currentPosition / 1000 22 | } else { 23 | field / 1000 24 | } 25 | } 26 | set(value) { 27 | field = value * 1000 28 | } 29 | 30 | init { 31 | setListen() 32 | } 33 | 34 | fun setChangedNotify(event: String) { 35 | Timber.i("setChangedNotify $event") 36 | firePropertyChange(event, null, event) 37 | } 38 | 39 | fun play(fd: FileDescriptor) { 40 | if (mediaPlayer.isPlaying) { 41 | mediaPlayer.stop() 42 | } 43 | mediaPlayer.reset() 44 | 45 | mediaPlayer.setDataSource(fd) 46 | mediaPlayer.prepareAsync() 47 | } 48 | 49 | fun seekTo(progress: Int) { 50 | playerProgress = progress * 1000 51 | mediaPlayer.seekTo(playerProgress) 52 | } 53 | 54 | fun pause() { 55 | playerProgress = mediaPlayer.currentPosition 56 | 57 | if (mediaPlayer.isPlaying) { 58 | mediaPlayer.pause() 59 | } 60 | 61 | setChangedNotify(ACTION_PAUSE) 62 | } 63 | 64 | fun stop() { 65 | if (mediaPlayer.isPlaying) { 66 | mediaPlayer.stop() 67 | mediaPlayer.release() 68 | } 69 | } 70 | 71 | private fun setListen() { 72 | mediaPlayer.setOnPreparedListener { 73 | mediaPlayer.seekTo(playerProgress) 74 | mediaPlayer.start() 75 | 76 | setChangedNotify(ACTION_PLAY) 77 | } 78 | 79 | mediaPlayer.setOnCompletionListener { 80 | setChangedNotify(ACTION_COMPLETE) 81 | } 82 | 83 | mediaPlayer.setOnErrorListener { mp, what, extra -> 84 | Timber.e("MediaPlayer error type:$what, code:$extra, currentPosition:${mp.currentPosition}") 85 | return@setOnErrorListener false 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/avd_play_to_pause.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/avd_pause_to_play.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | 16 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/layout/notification_large.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 22 | 23 | 35 | 36 | 46 | 47 | 55 | 56 | 63 | 64 | 71 | 72 | 73 | 84 | 85 | -------------------------------------------------------------------------------- /app/src/main/res/layout/adapter_song_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 17 | 18 | 29 | 30 | 46 | 47 | 63 | 64 | 74 | 75 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/songList/SongListPresenter.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.songList 2 | 3 | import android.app.DownloadManager 4 | import android.content.Context 5 | import android.net.Uri 6 | import android.os.Environment 7 | import android.util.Patterns 8 | import android.util.SparseArray 9 | import com.a1573595.musicplayer.BasePresenter 10 | import com.a1573595.musicplayer.R 11 | import com.a1573595.musicplayer.model.Song 12 | import com.a1573595.musicplayer.player.PlayerService 13 | import kotlinx.coroutines.* 14 | 15 | class SongListPresenter constructor(view: SongListView) : BasePresenter(view) { 16 | private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job()) 17 | 18 | private lateinit var player: PlayerService 19 | 20 | private lateinit var adapter: SongListAdapter 21 | private val filteredSongList: SparseArray = SparseArray() 22 | 23 | fun setPlayerManager(player: PlayerService) { 24 | this.player = player 25 | 26 | loadSongList() 27 | } 28 | 29 | fun setAdapter(adapter: SongListAdapter) { 30 | this.adapter = adapter 31 | } 32 | 33 | fun fetchSongState() { 34 | player.getSong()?.let { 35 | view.updateSongState(it, player.isPlaying()) 36 | } 37 | } 38 | 39 | fun filterSong(key: String) { 40 | scope.launch { 41 | filteredSongList.clear() 42 | 43 | val list = mutableListOf() 44 | player.getSongList().forEachIndexed { index, song -> 45 | if (song.name.contains(key, true) || song.author.contains(key, true)) { 46 | filteredSongList.put(index, song) 47 | list.add(song) 48 | } 49 | } 50 | 51 | withContext(Dispatchers.Main) { 52 | adapter.submitList(list) 53 | } 54 | } 55 | } 56 | 57 | fun onSongPlay() { 58 | if (!player.isPlaying()) { 59 | player.play() 60 | } else { 61 | player.pause() 62 | } 63 | } 64 | 65 | fun onSongClick(index: Int) { 66 | view.onSongClick() 67 | 68 | val position = filteredSongList.keyAt(index) 69 | playSong(position) 70 | } 71 | 72 | fun downloadSong(url: String) { 73 | if (Patterns.WEB_URL.matcher(url).matches() && isSupport(url)) { 74 | val uri: Uri = Uri.parse(url) 75 | 76 | val downloadManager: DownloadManager = 77 | view.context().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 78 | 79 | val request: DownloadManager.Request = DownloadManager.Request(uri) 80 | request.setDestinationInExternalPublicDir( 81 | Environment.DIRECTORY_MUSIC, 82 | uri.lastPathSegment 83 | ) 84 | request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) 85 | downloadManager.enqueue(request) 86 | } else { 87 | view.showToast(view.context().getString(R.string.unsupported_format)) 88 | } 89 | } 90 | 91 | private fun loadSongList() { 92 | scope.launch { 93 | view.showLoading() 94 | 95 | player.readSong() 96 | player.getSongList().forEachIndexed { index, song -> filteredSongList.put(index, song) } 97 | 98 | withContext(Dispatchers.Main) { 99 | view.stopLoading() 100 | adapter.submitList(player.getSongList()) 101 | fetchSongState() 102 | } 103 | } 104 | } 105 | 106 | private fun playSong(position: Int) { 107 | player.play(position) 108 | } 109 | 110 | private fun isSupport(extension: String): Boolean { 111 | return extension.endsWith("mp3") || extension.endsWith("wav") 112 | || extension.endsWith("ogg") || extension.endsWith("flac") 113 | } 114 | } -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 123 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/BaseSongActivity.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer 2 | 3 | import android.Manifest.permission.READ_EXTERNAL_STORAGE 4 | import android.Manifest.permission.READ_MEDIA_AUDIO 5 | import android.content.* 6 | import android.content.pm.PackageManager.PERMISSION_GRANTED 7 | import android.net.Uri 8 | import android.os.Build 9 | import android.os.Bundle 10 | import android.os.IBinder 11 | import android.provider.Settings 12 | import com.a1573595.musicplayer.player.PlayerService 13 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 14 | import java.beans.PropertyChangeListener 15 | 16 | abstract class BaseSongActivity

> : BaseActivity

(), PropertyChangeListener { 17 | private val REQUEST_WRITE_EXTERNAL_STORAGE: Int = 10 18 | private val REQUEST_READ_MEDIA_AUDIO: Int = 11 19 | 20 | private lateinit var player: PlayerService 21 | 22 | private var isBound: Boolean = false 23 | 24 | private val mConnection = object : ServiceConnection { 25 | override fun onServiceConnected(p0: ComponentName?, binder: IBinder) { 26 | val localBinder = binder as PlayerService.LocalBinder 27 | 28 | localBinder.service?.let { 29 | player = it 30 | 31 | player.addPlayerObserver(this@BaseSongActivity) 32 | isBound = true 33 | playerBound(player) 34 | } 35 | } 36 | 37 | override fun onServiceDisconnected(p0: ComponentName?) { 38 | isBound = false 39 | } 40 | } 41 | 42 | override fun onCreate(savedInstanceState: Bundle?) { 43 | super.onCreate(savedInstanceState) 44 | 45 | checkPermission() 46 | } 47 | 48 | override fun onStart() { 49 | super.onStart() 50 | 51 | val intent = Intent(this, PlayerService::class.java) 52 | startService(intent) 53 | 54 | if ((hasPermission(READ_MEDIA_AUDIO) || hasPermission(READ_EXTERNAL_STORAGE)) && !isBound) { 55 | bindService(intent, mConnection, Context.BIND_AUTO_CREATE) 56 | } 57 | } 58 | 59 | override fun onRestart() { 60 | super.onRestart() 61 | 62 | if (isBound) { 63 | player.addPlayerObserver(this) 64 | updateState() 65 | } 66 | } 67 | 68 | override fun onStop() { 69 | super.onStop() 70 | 71 | if (isBound) { 72 | player.deletePlayerObserver(this) 73 | } 74 | } 75 | 76 | override fun onDestroy() { 77 | super.onDestroy() 78 | 79 | if (isBound) { 80 | isBound = false 81 | unbindService(mConnection) 82 | } 83 | } 84 | 85 | override fun onRequestPermissionsResult( 86 | requestCode: Int, 87 | permissions: Array, 88 | grantResults: IntArray 89 | ) { 90 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 91 | 92 | if (grantResults.isEmpty()) return 93 | when (requestCode) { 94 | REQUEST_WRITE_EXTERNAL_STORAGE, REQUEST_READ_MEDIA_AUDIO -> if (grantResults[0] == PERMISSION_GRANTED) { 95 | val intent = Intent(this, PlayerService::class.java) 96 | bindService(intent, mConnection, Context.BIND_AUTO_CREATE) 97 | } else { 98 | showNeedPermissionDialog() 99 | } 100 | } 101 | } 102 | 103 | private fun checkPermission() { 104 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 105 | if (!hasPermission(READ_MEDIA_AUDIO)) { 106 | requestPermission(REQUEST_READ_MEDIA_AUDIO, READ_MEDIA_AUDIO) 107 | } 108 | } else if (!hasPermission(READ_EXTERNAL_STORAGE)) { 109 | requestPermission(REQUEST_WRITE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE) 110 | } 111 | } 112 | 113 | private fun showNeedPermissionDialog() { 114 | MaterialAlertDialogBuilder(this, R.style.AnimationDialog) 115 | .setTitle(getString(R.string.permission_requirement)) 116 | .setMessage(getString(R.string.need_permission_to_access)) 117 | .setPositiveButton(getString(R.string.agree)) { dialog, _ -> 118 | dialog.dismiss() 119 | openAPPSettings() 120 | } 121 | .setNegativeButton(getString(R.string.disagree)) { dialog, _ -> 122 | dialog.dismiss() 123 | finish() 124 | } 125 | .show() 126 | } 127 | 128 | private fun openAPPSettings() { 129 | val intent = Intent( 130 | Settings.ACTION_APPLICATION_DETAILS_SETTINGS, 131 | Uri.fromParts("package", packageName, null) 132 | ) 133 | startActivity(intent) 134 | } 135 | 136 | abstract fun playerBound(player: PlayerService) 137 | 138 | abstract fun updateState() 139 | } -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 48 | 49 | 50 | 51 | 52 | 53 | 55 | 56 | 57 | 58 | 59 | 1.8 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_song_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 17 | 18 | 23 | 24 | 42 | 43 | 52 | 53 | 54 | 55 | 56 | 64 | 65 | 74 | 75 | 78 | 79 | 91 | 92 | 109 | 110 | 125 | 126 | 127 | 128 | 137 | 138 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_play_song.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 21 | 22 | 33 | 34 | 44 | 45 | 51 | 52 | 53 | 67 | 68 | 79 | 80 | 91 | 92 | 102 | 103 | 112 | 113 | 125 | 126 | 127 | 137 | 138 | 147 | 148 | 160 | 161 | 173 | 174 | 185 | -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/songList/SongListActivity.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.songList 2 | 3 | import android.animation.ValueAnimator 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Bundle 8 | import android.os.Handler 9 | import android.os.Looper 10 | import android.view.LayoutInflater 11 | import android.view.View 12 | import android.view.animation.Animation 13 | import android.view.animation.AnimationUtils 14 | import android.view.animation.LayoutAnimationController 15 | import android.view.animation.LinearInterpolator 16 | import android.view.inputmethod.InputMethodManager 17 | import android.widget.Toast 18 | import androidx.activity.addCallback 19 | import androidx.appcompat.app.AlertDialog 20 | import androidx.core.app.ActivityOptionsCompat 21 | import androidx.core.content.ContextCompat 22 | import androidx.core.util.Pair 23 | import androidx.core.widget.addTextChangedListener 24 | import androidx.lifecycle.lifecycleScope 25 | import androidx.recyclerview.widget.LinearLayoutManager 26 | import com.a1573595.musicplayer.* 27 | import com.a1573595.musicplayer.databinding.ActivitySongListBinding 28 | import com.a1573595.musicplayer.databinding.DialogLoadingBinding 29 | import com.a1573595.musicplayer.model.Song 30 | import com.a1573595.musicplayer.playSong.PlaySongActivity 31 | import com.a1573595.musicplayer.player.PlayerManager 32 | import com.a1573595.musicplayer.player.PlayerService 33 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 34 | import kotlinx.coroutines.launch 35 | import java.beans.PropertyChangeEvent 36 | 37 | class SongListActivity : BaseSongActivity(), SongListView { 38 | private lateinit var viewBinding: ActivitySongListBinding 39 | 40 | private var loadingDialog: AlertDialog? = null 41 | 42 | private lateinit var wheelAnimation: Animation 43 | 44 | private val backHandler = Handler(Looper.getMainLooper()) 45 | 46 | override fun onCreate(savedInstanceState: Bundle?) { 47 | super.onCreate(savedInstanceState) 48 | 49 | registerOnBackPress() 50 | 51 | viewBinding = ActivitySongListBinding.inflate(layoutInflater) 52 | setContentView(viewBinding.root) 53 | setBackground() 54 | 55 | initElementAnimation() 56 | initRecyclerView() 57 | } 58 | 59 | override fun onDestroy() { 60 | loadingDialog?.dismiss() 61 | loadingDialog = null 62 | 63 | super.onDestroy() 64 | } 65 | 66 | override fun playerBound(player: PlayerService) { 67 | presenter.setPlayerManager(player) 68 | 69 | setListen() 70 | } 71 | 72 | override fun updateState() { 73 | presenter.filterSong(viewBinding.edName.text.toString()) 74 | presenter.fetchSongState() 75 | } 76 | 77 | override fun createPresenter(): SongListPresenter = SongListPresenter(this) 78 | 79 | override fun showLoading() { 80 | lifecycleScope.launch { 81 | val loadViewBinding = 82 | DialogLoadingBinding.inflate(LayoutInflater.from(this@SongListActivity)) 83 | 84 | val animator = ValueAnimator.ofInt(0, 8).apply { 85 | duration = 750 86 | interpolator = LinearInterpolator() 87 | repeatCount = ValueAnimator.INFINITE 88 | 89 | addUpdateListener { 90 | loadViewBinding.imgLoad.rotation = (it.animatedValue as Int).toFloat() * 45 91 | loadViewBinding.imgLoad.requestLayout() 92 | } 93 | } 94 | 95 | loadingDialog = MaterialAlertDialogBuilder(context()).create().apply { 96 | window?.setBackgroundDrawableResource(android.R.color.transparent) 97 | setView(loadViewBinding.root) 98 | setCancelable(false) 99 | setOnDismissListener { 100 | animator.removeAllListeners() 101 | animator.cancel() 102 | } 103 | show() 104 | } 105 | 106 | animator.start() 107 | } 108 | } 109 | 110 | override fun stopLoading() { 111 | lifecycleScope.launch { 112 | loadingDialog?.dismiss() 113 | loadingDialog = null 114 | 115 | viewBinding.recyclerView.scheduleLayoutAnimation() 116 | } 117 | } 118 | 119 | override fun updateSongState(song: Song, isPlaying: Boolean) { 120 | lifecycleScope.launch { 121 | viewBinding.tvName.text = song.name 122 | viewBinding.tvArtist.text = song.author 123 | viewBinding.btnPlay.setImageResource(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play) 124 | 125 | if (isPlaying) { 126 | viewBinding.imgDisc.startAnimation(wheelAnimation) 127 | } else { 128 | viewBinding.imgDisc.clearAnimation() 129 | } 130 | } 131 | } 132 | 133 | override fun propertyChange(event: PropertyChangeEvent) { 134 | when (event.propertyName) { 135 | PlayerManager.ACTION_PLAY, PlayerManager.ACTION_PAUSE -> { 136 | presenter.fetchSongState() 137 | } 138 | PlayerService.ACTION_FIND_NEW_SONG, PlayerService.ACTION_NOT_SONG_FOUND -> { 139 | presenter.filterSong(viewBinding.edName.text.toString()) 140 | } 141 | } 142 | } 143 | 144 | override fun onSongClick() { 145 | hideKeyBoard() 146 | viewBinding.bottomAppBar.performShow() 147 | } 148 | 149 | private fun registerOnBackPress() { 150 | onBackPressedDispatcher.addCallback { 151 | if (backHandler.hasMessages(0)) { 152 | finish() 153 | } else { 154 | showToast(getString(R.string.press_again_to_exit)) 155 | backHandler.removeCallbacksAndMessages(null) 156 | backHandler.postDelayed({}, 2000) 157 | } 158 | } 159 | } 160 | private fun setBackground() { 161 | viewBinding.root.background = ContextCompat.getDrawable(this, R.drawable.background_music) 162 | viewBinding.root.background.alpha = 30 163 | } 164 | 165 | private fun initElementAnimation() { 166 | wheelAnimation = AnimationUtils.loadAnimation(this, R.anim.rotation_wheel) 167 | wheelAnimation.duration = 1000 168 | wheelAnimation.repeatCount = ValueAnimator.INFINITE 169 | } 170 | 171 | private fun initRecyclerView() { 172 | viewBinding.recyclerView.layoutManager = LinearLayoutManager(this) 173 | val adapter = SongListAdapter(presenter) 174 | viewBinding.recyclerView.adapter = adapter 175 | presenter.setAdapter(adapter) 176 | 177 | val controller = LayoutAnimationController( 178 | AnimationUtils.loadAnimation(this, R.anim.fade_in_from_bottom) 179 | ) 180 | controller.order = LayoutAnimationController.ORDER_NORMAL 181 | controller.delay = 0.3f 182 | viewBinding.recyclerView.layoutAnimation = controller 183 | } 184 | 185 | private fun setListen() { 186 | viewBinding.edName.addTextChangedListener { 187 | presenter.filterSong(it.toString()) 188 | } 189 | 190 | viewBinding.imgInfo.setOnClickListener { 191 | openGithub() 192 | } 193 | 194 | viewBinding.btnPlay.setOnClickListener { 195 | presenter.onSongPlay() 196 | 197 | viewBinding.bottomAppBar.performShow() 198 | } 199 | 200 | viewBinding.bottomAppBar.setOnClickListener { 201 | if (viewBinding.tvName.text.isNotEmpty() || viewBinding.tvArtist.text.isNotEmpty()) { 202 | val p1: Pair = 203 | Pair.create(viewBinding.imgDisc, viewBinding.imgDisc.transitionName) 204 | val p2: Pair = 205 | Pair.create(viewBinding.tvName, viewBinding.tvName.transitionName) 206 | val p3: Pair = 207 | Pair.create(viewBinding.btnPlay, viewBinding.btnPlay.transitionName) 208 | 209 | val options = ActivityOptionsCompat.makeSceneTransitionAnimation(this, p1, p2, p3) 210 | 211 | startActivity(Intent(this, PlaySongActivity::class.java), options.toBundle()) 212 | } 213 | } 214 | } 215 | 216 | private fun openGithub() { 217 | val intent = 218 | Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/a1573595/MusicPlayer")) 219 | 220 | if (intent.resolveActivity(packageManager) != null) { 221 | startActivity(intent) 222 | } else { 223 | Toast.makeText( 224 | this, 225 | getString(R.string.cant_open_browser), 226 | Toast.LENGTH_SHORT 227 | ).show() 228 | } 229 | } 230 | 231 | private fun hideKeyBoard() { 232 | val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager 233 | imm.hideSoftInputFromWindow(viewBinding.edName.windowToken, 0) 234 | } 235 | } -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/playSong/PlaySongActivity.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.playSong 2 | 3 | import android.animation.ValueAnimator 4 | import android.graphics.Point 5 | import android.os.Bundle 6 | import android.transition.ChangeBounds 7 | import android.view.View 8 | import android.view.animation.Animation 9 | import android.view.animation.AnimationUtils 10 | import android.view.animation.DecelerateInterpolator 11 | import android.widget.FrameLayout 12 | import android.widget.ImageView 13 | import android.widget.LinearLayout 14 | import android.widget.SeekBar 15 | import androidx.core.content.ContextCompat 16 | import androidx.core.view.ViewCompat 17 | import androidx.core.view.WindowCompat 18 | import androidx.core.view.WindowInsetsCompat 19 | import androidx.core.view.WindowInsetsControllerCompat 20 | import com.a1573595.musicplayer.BaseSongActivity 21 | import com.a1573595.musicplayer.R 22 | import com.a1573595.musicplayer.customView.FloatingAnimationView 23 | import com.a1573595.musicplayer.databinding.ActivityPlaySongBinding 24 | import com.a1573595.musicplayer.model.Song 25 | import com.a1573595.musicplayer.model.TimeUtil 26 | import com.a1573595.musicplayer.player.PlayerManager 27 | import com.a1573595.musicplayer.player.PlayerService 28 | import java.beans.PropertyChangeEvent 29 | 30 | class PlaySongActivity : BaseSongActivity(), PlaySongView { 31 | companion object { 32 | private val STATE_PLAY = intArrayOf(R.attr.state_pause) 33 | private val STATE_PAUSE = intArrayOf(-R.attr.state_pause) 34 | } 35 | 36 | private lateinit var viewBinding: ActivityPlaySongBinding 37 | 38 | private lateinit var wheelAnimation: Animation 39 | private lateinit var scaleAnimation: Animation 40 | 41 | private lateinit var seekBarUpdateRunnable: Runnable 42 | private val seekBarUpdateDelayMillis: Long = 1000 43 | 44 | private lateinit var favoriteAnimationRunnable: Runnable 45 | private val favoriteAnimationDelayMillis: Long = 300 46 | 47 | override fun onCreate(savedInstanceState: Bundle?) { 48 | super.onCreate(savedInstanceState) 49 | hideStatusBar() 50 | 51 | viewBinding = ActivityPlaySongBinding.inflate(layoutInflater) 52 | setScreenHigh() 53 | setContentView(viewBinding.root) 54 | setBackground() 55 | 56 | viewBinding.tvName.isSelected = true 57 | 58 | initWindowAnimations() 59 | } 60 | 61 | override fun onStop() { 62 | super.onStop() 63 | viewBinding.imgFavorite.removeCallbacks(favoriteAnimationRunnable) 64 | viewBinding.seekBar.removeCallbacks(seekBarUpdateRunnable) 65 | } 66 | 67 | override fun playerBound(player: PlayerService) { 68 | initElementAnimation() 69 | initFavoriteRunnable() 70 | initSeekBarUpdateRunnable() 71 | 72 | presenter.setPlayerManager(player) 73 | 74 | setListen() 75 | } 76 | 77 | override fun updateState() { 78 | presenter.fetchSongState() 79 | } 80 | 81 | override fun createPresenter(): PlaySongPresenter = PlaySongPresenter(this) 82 | 83 | override fun updateSongState(song: Song, isPlaying: Boolean, progress: Int) { 84 | viewBinding.imgFavorite.removeCallbacks(favoriteAnimationRunnable) 85 | viewBinding.seekBar.removeCallbacks(seekBarUpdateRunnable) 86 | 87 | viewBinding.tvName.text = song.name 88 | viewBinding.tvDuration.text = TimeUtil.timeMillisToTime(song.duration) 89 | viewBinding.seekBar.max = (song.duration / 1000).toInt() 90 | viewBinding.seekBar.progress = progress 91 | viewBinding.tvProgress.text = 92 | TimeUtil.timeMillisToTime((viewBinding.seekBar.progress * 1000).toLong()) 93 | viewBinding.imgPlay.setImageState(if (isPlaying) STATE_PLAY else STATE_PAUSE, false) 94 | 95 | if (isPlaying) { 96 | viewBinding.imgFavorite.post(favoriteAnimationRunnable) 97 | viewBinding.flDisc.startAnimation(wheelAnimation) 98 | viewBinding.seekBar.postDelayed(seekBarUpdateRunnable, seekBarUpdateDelayMillis) 99 | } else { 100 | viewBinding.flDisc.clearAnimation() 101 | } 102 | } 103 | 104 | override fun showRepeat(isRepeat: Boolean) { 105 | viewBinding.imgRepeat.imageAlpha = if (isRepeat) 255 else 80 106 | } 107 | 108 | override fun showRandom(isRandom: Boolean) { 109 | viewBinding.imgRandom.imageAlpha = if (isRandom) 255 else 80 110 | } 111 | 112 | override fun propertyChange(event: PropertyChangeEvent) { 113 | when (event.propertyName) { 114 | PlayerManager.ACTION_PLAY, PlayerManager.ACTION_PAUSE -> { 115 | updateState() 116 | } 117 | } 118 | } 119 | 120 | private fun hideStatusBar() { 121 | // window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY 122 | // or View.SYSTEM_UI_FLAG_LAYOUT_STABLE 123 | // or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 124 | // or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 125 | // or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION 126 | // or View.SYSTEM_UI_FLAG_FULLSCREEN 127 | // or View.SYSTEM_UI_FLAG_LOW_PROFILE) 128 | 129 | WindowCompat.setDecorFitsSystemWindows(window, false) 130 | 131 | WindowInsetsControllerCompat(window, window.decorView).apply { 132 | // Hide the status bar 133 | hide(WindowInsetsCompat.Type.statusBars()) 134 | // Allow showing the status bar with swiping from top to bottom 135 | systemBarsBehavior = 136 | WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 137 | } 138 | } 139 | 140 | private fun setBackground() { 141 | viewBinding.root.background = ContextCompat.getDrawable(this, R.drawable.background_music) 142 | viewBinding.root.background.alpha = 30 143 | } 144 | 145 | private fun setScreenHigh() { 146 | ViewCompat.setOnApplyWindowInsetsListener( 147 | viewBinding.root 148 | ) { view: View, windowInsets: WindowInsetsCompat -> 149 | val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) 150 | view.layoutParams = (view.layoutParams as FrameLayout.LayoutParams).apply { 151 | // draw on top of the bottom navigation bar 152 | bottomMargin = insets.bottom 153 | } 154 | 155 | // Return CONSUMED if you don't want the window insets to keep being 156 | // passed down to descendant views. 157 | WindowInsetsCompat.CONSUMED 158 | } 159 | } 160 | 161 | private fun initWindowAnimations() { 162 | val enterTransition = ChangeBounds() 163 | enterTransition.duration = 1000 164 | enterTransition.interpolator = DecelerateInterpolator() 165 | window.sharedElementEnterTransition = enterTransition 166 | } 167 | 168 | private fun initElementAnimation() { 169 | wheelAnimation = AnimationUtils.loadAnimation(this, R.anim.rotation_wheel) 170 | wheelAnimation.duration = 1000 171 | wheelAnimation.repeatCount = ValueAnimator.INFINITE 172 | 173 | scaleAnimation = AnimationUtils.loadAnimation(this, R.anim.zoom_in) 174 | scaleAnimation.duration = 200 175 | scaleAnimation.repeatCount = 1 176 | scaleAnimation.repeatMode = Animation.REVERSE 177 | } 178 | 179 | private fun initSeekBarUpdateRunnable() { 180 | seekBarUpdateRunnable = Runnable { 181 | viewBinding.seekBar.progress = viewBinding.seekBar.progress + 1 182 | viewBinding.seekBar.postDelayed(seekBarUpdateRunnable, seekBarUpdateDelayMillis) 183 | } 184 | } 185 | 186 | private fun initFavoriteRunnable() { 187 | val position = IntArray(2) 188 | viewBinding.imgFavorite.getLocationInWindow(position) 189 | val startPoint = Point((position[0]), (position[1])) 190 | 191 | val favoriteDrawable = viewBinding.imgFavorite.drawable 192 | 193 | favoriteAnimationRunnable = Runnable { 194 | with(FloatingAnimationView(this)) { 195 | setImageDrawable(favoriteDrawable) 196 | scaleType = ImageView.ScaleType.CENTER_INSIDE 197 | layoutParams = LinearLayout.LayoutParams(80, 80) 198 | startPosition = startPoint 199 | endPosition = Point(0, 0) 200 | 201 | viewBinding.root.addView(this) 202 | 203 | this.startAnimation() 204 | } 205 | 206 | viewBinding.imgFavorite.postDelayed( 207 | favoriteAnimationRunnable, 208 | favoriteAnimationDelayMillis 209 | ) 210 | } 211 | } 212 | 213 | private fun setListen() { 214 | viewBinding.imgBack.setOnClickListener { 215 | onBackPressedDispatcher.onBackPressed() 216 | } 217 | 218 | viewBinding.imgRepeat.setOnClickListener { 219 | viewBinding.imgRepeat.imageAlpha = if (presenter.updateRepeat()) 255 else 80 220 | } 221 | 222 | viewBinding.imgRandom.setOnClickListener { 223 | viewBinding.imgRandom.imageAlpha = if (presenter.updateRandom()) 255 else 80 224 | } 225 | 226 | viewBinding.seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { 227 | override fun onProgressChanged(s: SeekBar, progress: Int, fromUser: Boolean) { 228 | if (fromUser) { 229 | viewBinding.seekBar.removeCallbacks(seekBarUpdateRunnable) 230 | } 231 | 232 | viewBinding.tvProgress.text = 233 | TimeUtil.timeMillisToTime((viewBinding.seekBar.progress * 1000).toLong()) 234 | } 235 | 236 | override fun onStartTrackingTouch(s: SeekBar) = Unit 237 | 238 | override fun onStopTrackingTouch(s: SeekBar) { 239 | viewBinding.seekBar.removeCallbacks(seekBarUpdateRunnable) 240 | 241 | presenter.seekTo(s.progress) 242 | viewBinding.tvProgress.text = 243 | TimeUtil.timeMillisToTime((viewBinding.seekBar.progress * 1000).toLong()) 244 | 245 | viewBinding.seekBar.postDelayed(seekBarUpdateRunnable, seekBarUpdateDelayMillis) 246 | } 247 | }) 248 | 249 | viewBinding.imgBackward.setOnClickListener { 250 | presenter.skipToPrevious() 251 | 252 | it.startAnimation(scaleAnimation) 253 | } 254 | 255 | viewBinding.imgPlay.setOnClickListener { 256 | presenter.onSongPlay() 257 | } 258 | 259 | viewBinding.imgForward.setOnClickListener { 260 | presenter.skipToNext() 261 | 262 | it.startAnimation(scaleAnimation) 263 | } 264 | } 265 | } -------------------------------------------------------------------------------- /app/src/main/java/com/a1573595/musicplayer/player/PlayerService.kt: -------------------------------------------------------------------------------- 1 | package com.a1573595.musicplayer.player 2 | 3 | import android.app.* 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.IntentFilter 8 | import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK 9 | import android.media.MediaMetadataRetriever 10 | import android.net.Uri 11 | import android.os.* 12 | import android.provider.MediaStore 13 | import android.widget.RemoteViews 14 | import android.widget.Toast 15 | import androidx.core.app.NotificationCompat 16 | import com.a1573595.musicplayer.R 17 | import com.a1573595.musicplayer.Weak 18 | import com.a1573595.musicplayer.model.Song 19 | import com.a1573595.musicplayer.player.PlayerManager.Companion.ACTION_COMPLETE 20 | import com.a1573595.musicplayer.player.PlayerManager.Companion.ACTION_PAUSE 21 | import com.a1573595.musicplayer.player.PlayerManager.Companion.ACTION_PLAY 22 | import com.a1573595.musicplayer.player.PlayerManager.Companion.ACTION_STOP 23 | import com.a1573595.musicplayer.songList.SongListActivity 24 | import kotlinx.coroutines.Dispatchers 25 | import kotlinx.coroutines.withContext 26 | import timber.log.Timber 27 | import java.beans.PropertyChangeEvent 28 | import java.beans.PropertyChangeListener 29 | import java.io.FileDescriptor 30 | import java.lang.Exception 31 | 32 | class PlayerService : Service(), PropertyChangeListener { 33 | companion object { 34 | const val CHANNEL_ID_MUSIC = "app.MUSIC" 35 | const val CHANNEL_NAME_MUSIC = "Music" 36 | const val NOTIFICATION_ID_MUSIC = 101 37 | 38 | const val BROADCAST_ID_MUSIC = 201 39 | const val NOTIFICATION_PREVIOUS = "notification.PREVIOUS" 40 | const val NOTIFICATION_PLAY = "notification.PLAY" 41 | const val NOTIFICATION_NEXT = "notification.NEXT" 42 | const val NOTIFICATION_CANCEL = "notification.CANCEL" 43 | 44 | const val ACTION_FIND_NEW_SONG = "action.FIND_NEW_SONG" 45 | const val ACTION_NOT_SONG_FOUND = "action.NOT_FOUND" 46 | } 47 | 48 | private lateinit var smallRemoteView: RemoteViews 49 | private lateinit var largeRemoteView: RemoteViews 50 | private lateinit var intentPREVIOUS: PendingIntent 51 | private lateinit var intentPlay: PendingIntent 52 | private lateinit var intentNext: PendingIntent 53 | private lateinit var intentCancel: PendingIntent 54 | 55 | private val receiver = object : BroadcastReceiver() { 56 | override fun onReceive(p0: Context?, intent: Intent?) { 57 | when (intent?.action) { 58 | NOTIFICATION_PREVIOUS -> skipToPrevious() 59 | NOTIFICATION_PLAY -> { 60 | if (isPlaying) { 61 | pause() 62 | } else { 63 | play() 64 | } 65 | } 66 | NOTIFICATION_NEXT -> skipToNext() 67 | NOTIFICATION_CANCEL -> { 68 | pause() 69 | 70 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 71 | stopForeground(STOP_FOREGROUND_DETACH) 72 | } else { 73 | stopForeground(true) 74 | } 75 | stopSelf() 76 | } 77 | } 78 | } 79 | } 80 | 81 | private val metaRetriever = MediaMetadataRetriever() 82 | private val uriExternal: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 83 | private val mHandler: Handler = Handler(Looper.getMainLooper()) { msg -> 84 | val id = msg.data.getString("songID") 85 | val audioUri = Uri.withAppendedPath(uriExternal, id) 86 | 87 | try { 88 | contentResolver.openFileDescriptor(audioUri, "r")?.use { 89 | if (addSong(it.fileDescriptor, id!!, getSongTitle(audioUri))) { 90 | playerManager.setChangedNotify(ACTION_FIND_NEW_SONG) 91 | } 92 | } 93 | } catch (e: Exception) { 94 | Timber.e(e) 95 | } 96 | 97 | true 98 | } 99 | private val audioObserver: AudioObserver = AudioObserver(mHandler) 100 | 101 | private val playerManager: PlayerManager = PlayerManager() 102 | 103 | private val songList: MutableList = mutableListOf() 104 | private var playerPosition: Int = 0 // song queue position 105 | private var isPlaying: Boolean = false // mediaPlayer.isPlaying may take some time update status 106 | var isRepeat: Boolean = false 107 | var isRandom: Boolean = false 108 | 109 | inner class LocalBinder : Binder() { 110 | // Return this instance of PlayerService so clients can call public methods 111 | // val service: PlayerService = this@PlayerService 112 | 113 | val service by Weak { 114 | this@PlayerService 115 | } 116 | } 117 | 118 | private var binder: LocalBinder? = null 119 | 120 | override fun onCreate() { 121 | super.onCreate() 122 | 123 | createNotificationChannel() 124 | initRemoteView() 125 | 126 | contentResolver.registerContentObserver(uriExternal, true, audioObserver) 127 | registerReceiver() 128 | addPlayerObserver(this) 129 | 130 | binder = LocalBinder() 131 | } 132 | 133 | override fun onBind(intent: Intent): IBinder? { 134 | return binder 135 | } 136 | 137 | override fun onTaskRemoved(rootIntent: Intent) { 138 | super.onTaskRemoved(rootIntent) 139 | 140 | stopSelf() 141 | } 142 | 143 | override fun onDestroy() { 144 | binder = null 145 | 146 | contentResolver.unregisterContentObserver(audioObserver) 147 | unregisterReceiver(receiver) 148 | metaRetriever.release() 149 | 150 | deletePlayerObserver(this) 151 | playerManager.stop() 152 | 153 | super.onDestroy() 154 | } 155 | 156 | override fun propertyChange(event: PropertyChangeEvent) { 157 | when (event.propertyName) { 158 | ACTION_COMPLETE -> { 159 | playerManager.playerProgress = 0 160 | 161 | when { 162 | isRepeat -> play() 163 | isRandom -> play((0 until songList.size).random()) 164 | else -> skipToNext() 165 | } 166 | } 167 | ACTION_PLAY, ACTION_PAUSE -> { 168 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 169 | startForeground(NOTIFICATION_ID_MUSIC, createNotification(), FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) 170 | } else { 171 | startForeground(NOTIFICATION_ID_MUSIC, createNotification()) 172 | } 173 | } 174 | ACTION_STOP -> { 175 | isPlaying = false 176 | } 177 | ACTION_FIND_NEW_SONG -> { 178 | Toast.makeText(this, getString(R.string.found_new_song), Toast.LENGTH_SHORT).show() 179 | } 180 | ACTION_NOT_SONG_FOUND -> { 181 | Toast.makeText(this, getString(R.string.no_song_found), Toast.LENGTH_SHORT).show() 182 | } 183 | } 184 | } 185 | 186 | private fun addSong(fd: FileDescriptor, id: String, title: String): Boolean { 187 | try { 188 | if (!fd.valid()) { 189 | return false 190 | } 191 | 192 | metaRetriever.setDataSource(fd) 193 | 194 | val duration = 195 | metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) 196 | val artist = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) 197 | val author = metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_AUTHOR) 198 | 199 | if (duration.isNullOrEmpty()) { 200 | return false 201 | } 202 | 203 | val song = Song( 204 | id, title, artist ?: author ?: getString(R.string.unknown), duration.toLong() 205 | ) 206 | 207 | if (!songList.contains(song)) { 208 | songList.add(song) 209 | } 210 | } catch (e: Exception) { 211 | Timber.e(e) 212 | return false 213 | } 214 | return true 215 | } 216 | 217 | suspend fun readSong() = withContext(Dispatchers.IO) { 218 | if (songList.isNotEmpty()) return@withContext 219 | 220 | contentResolver.query( 221 | uriExternal, null, null, null, null 222 | )?.use { cursor -> 223 | val indexID: Int = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) 224 | val indexTitle: Int = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) 225 | 226 | while (cursor.moveToNext()) { 227 | val id = cursor.getString(indexID) 228 | val title = cursor.getString(indexTitle) 229 | val audioUri = Uri.withAppendedPath(uriExternal, id) 230 | 231 | contentResolver.openFileDescriptor(audioUri, "r")?.use { 232 | addSong(it.fileDescriptor, id, title) 233 | } 234 | } 235 | } 236 | } 237 | 238 | fun addPlayerObserver(listener: PropertyChangeListener) = 239 | playerManager.addPropertyChangeListener(listener) 240 | 241 | fun deletePlayerObserver(listener: PropertyChangeListener) = 242 | playerManager.removePropertyChangeListener(listener) 243 | 244 | fun isPlaying(): Boolean = isPlaying 245 | 246 | fun getSongList() = songList.toList() 247 | 248 | fun getSong(): Song? = songList.getOrNull(playerPosition) 249 | 250 | fun getProgress(): Int = playerManager.playerProgress 251 | 252 | fun play(position: Int = playerPosition) { 253 | isPlaying = true 254 | 255 | // Is different song 256 | if (position != playerPosition) { 257 | playerManager.playerProgress = 0 258 | } 259 | 260 | playerPosition = when { 261 | songList.size < 1 -> { 262 | playerManager.setChangedNotify(ACTION_NOT_SONG_FOUND) 263 | return 264 | } 265 | position >= songList.size -> 0 266 | position < 0 -> songList.lastIndex 267 | else -> position 268 | } 269 | 270 | val audioUri = Uri.withAppendedPath(uriExternal, songList[playerPosition].id) 271 | 272 | contentResolver.openFileDescriptor(audioUri, "r")?.use { 273 | playerManager.play(it.fileDescriptor) 274 | } ?: kotlin.run { 275 | songList.removeAt(playerPosition) 276 | playerManager.setChangedNotify(ACTION_NOT_SONG_FOUND) 277 | 278 | play() 279 | } 280 | } 281 | 282 | fun pause() { 283 | isPlaying = false 284 | 285 | playerManager.pause() 286 | } 287 | 288 | fun seekTo(progress: Int) { 289 | if (isPlaying) { 290 | playerManager.seekTo(progress) 291 | } else { 292 | playerManager.playerProgress = progress 293 | play() 294 | } 295 | } 296 | 297 | fun skipToNext() { 298 | if (!isRandom) { 299 | play(playerPosition + 1) 300 | } else { 301 | play((0 until songList.size).random()) 302 | } 303 | } 304 | 305 | fun skipToPrevious() { 306 | if (!isRandom) { 307 | play(playerPosition - 1) 308 | } else { 309 | play((0 until songList.size).random()) 310 | } 311 | } 312 | 313 | private fun getSongTitle(uri: Uri): String { 314 | var title: String? = uri.lastPathSegment 315 | 316 | contentResolver.query( 317 | uri, null, null, null, null 318 | )?.use { 319 | if (it.moveToNext()) { 320 | title = it.getString(it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)) 321 | } 322 | } 323 | 324 | return title ?: "" 325 | } 326 | 327 | private fun createNotificationChannel() { 328 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 329 | val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 330 | 331 | val status = NotificationChannel( 332 | CHANNEL_ID_MUSIC, CHANNEL_NAME_MUSIC, NotificationManager.IMPORTANCE_LOW 333 | ) 334 | status.description = "Music player" 335 | nm.createNotificationChannel(status) 336 | } 337 | } 338 | 339 | private fun initRemoteView() { 340 | smallRemoteView = RemoteViews(packageName, R.layout.notification_small) 341 | largeRemoteView = RemoteViews(packageName, R.layout.notification_large) 342 | 343 | intentPREVIOUS = PendingIntent.getBroadcast( 344 | this, 345 | BROADCAST_ID_MUSIC, 346 | Intent(NOTIFICATION_PREVIOUS).setPackage(packageName), 347 | PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE 348 | ) 349 | 350 | intentPlay = PendingIntent.getBroadcast( 351 | this, 352 | BROADCAST_ID_MUSIC, 353 | Intent(NOTIFICATION_PLAY).setPackage(packageName), 354 | PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE 355 | ) 356 | 357 | intentNext = PendingIntent.getBroadcast( 358 | this, 359 | BROADCAST_ID_MUSIC, 360 | Intent(NOTIFICATION_NEXT).setPackage(packageName), 361 | PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE 362 | ) 363 | 364 | intentCancel = PendingIntent.getBroadcast( 365 | this, 366 | BROADCAST_ID_MUSIC, 367 | Intent(NOTIFICATION_CANCEL).setPackage(packageName), 368 | PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE 369 | ) 370 | } 371 | 372 | private fun registerReceiver() { 373 | val intentFilter = IntentFilter() 374 | intentFilter.addAction(NOTIFICATION_PREVIOUS) 375 | intentFilter.addAction(NOTIFICATION_PLAY) 376 | intentFilter.addAction(NOTIFICATION_NEXT) 377 | intentFilter.addAction(NOTIFICATION_CANCEL) 378 | 379 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 380 | registerReceiver(receiver, intentFilter, RECEIVER_EXPORTED) 381 | } else { 382 | registerReceiver(receiver, intentFilter) 383 | } 384 | } 385 | 386 | private fun createNotification(): Notification { 387 | val song = getSong() 388 | 389 | smallRemoteView.setTextViewText(R.id.tv_name, song?.name) 390 | smallRemoteView.setImageViewResource( 391 | R.id.img_play, if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play 392 | ) 393 | 394 | largeRemoteView.setTextViewText(R.id.tv_name, song?.name) 395 | largeRemoteView.setImageViewResource( 396 | R.id.img_play, if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play 397 | ) 398 | largeRemoteView.setOnClickPendingIntent(R.id.img_previous, intentPREVIOUS) 399 | largeRemoteView.setOnClickPendingIntent(R.id.img_play, intentPlay) 400 | largeRemoteView.setOnClickPendingIntent(R.id.img_next, intentNext) 401 | largeRemoteView.setOnClickPendingIntent(R.id.img_cancel, intentCancel) 402 | 403 | val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID_MUSIC) 404 | notificationBuilder.setSmallIcon(R.drawable.ic_music) 405 | // .setLargeIcon(BitmapFactory.decodeResource(this.resources, R.drawable.music)) 406 | .setContentTitle(song?.name).setContentText(song?.author) 407 | .setVisibility(NotificationCompat.VISIBILITY_PUBLIC).setOnlyAlertOnce(true) 408 | .setContentIntent(createContentIntent()) 409 | .setStyle(NotificationCompat.DecoratedCustomViewStyle()) 410 | .setCustomContentView(smallRemoteView) 411 | .setCustomBigContentView(largeRemoteView) //show full remoteView 412 | // .setOngoing(true) // not working when use startForeground() 413 | 414 | return notificationBuilder.build() 415 | } 416 | 417 | private fun createContentIntent(): PendingIntent { 418 | val intent = Intent(this, SongListActivity::class.java) 419 | intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK 420 | intent.action = Intent.ACTION_MAIN 421 | intent.addCategory(Intent.CATEGORY_LAUNCHER) 422 | 423 | return PendingIntent.getActivity( 424 | this, 425 | System.currentTimeMillis().toInt(), 426 | intent, 427 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 428 | ) 429 | } 430 | } --------------------------------------------------------------------------------