├── .github └── workflows │ └── android.yml ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── libs │ ├── arm64-v8a │ │ ├── libliquidfun.so │ │ └── libliquidfun_jni.so │ └── libliquidfun.jar ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── me │ │ └── spica27 │ │ └── spicamusic │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── me │ │ │ └── spica27 │ │ │ └── spicamusic │ │ │ ├── App.kt │ │ │ ├── MainActivity.kt │ │ │ ├── db │ │ │ ├── AppDatabase.kt │ │ │ ├── dao │ │ │ │ ├── PlaylistDao.kt │ │ │ │ └── SongDao.kt │ │ │ └── entity │ │ │ │ ├── Playlist.kt │ │ │ │ ├── PlaylistSongCrossRef.kt │ │ │ │ └── Song.kt │ │ │ ├── dsp │ │ │ ├── BandProcessor.kt │ │ │ ├── ByteUtils.kt │ │ │ ├── Equalizer.kt │ │ │ ├── EqualizerAudioProcessor.kt │ │ │ ├── EqualizerBand.kt │ │ │ ├── HighPassFilter.kt │ │ │ ├── LowPassFilter.kt │ │ │ ├── ReplayGainAudioProcessor.kt │ │ │ └── Utils.kt │ │ │ ├── module │ │ │ ├── NavigationModule.kt │ │ │ ├── PersistenceModule.kt │ │ │ └── ToolModule.kt │ │ │ ├── playback │ │ │ ├── PlaybackStateManager.kt │ │ │ └── RepeatMode.kt │ │ │ ├── player │ │ │ ├── IPlayer.kt │ │ │ └── Queue.kt │ │ │ ├── route │ │ │ └── Routes.kt │ │ │ ├── service │ │ │ ├── ForegroundManager.kt │ │ │ ├── ForegroundServiceNotification.kt │ │ │ ├── MuiscSearchWorker.kt │ │ │ ├── MusicService.kt │ │ │ └── notification │ │ │ │ ├── MediaSessionComponent.kt │ │ │ │ └── NotificationComponent.kt │ │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ │ ├── ui │ │ │ ├── AddSongScrren.kt │ │ │ ├── AppMain.kt │ │ │ ├── CurrentListPage.kt │ │ │ ├── EqScreen.kt │ │ │ ├── HomePage.kt │ │ │ ├── MainScreen.kt │ │ │ ├── PlayerPage.kt │ │ │ ├── PlayerScreen.kt │ │ │ ├── PlaylistDetailScreen.kt │ │ │ ├── SearchAllScreen.kt │ │ │ ├── SettingPage.kt │ │ │ └── SplashScreen.kt │ │ │ ├── utils │ │ │ ├── AppTools.kt │ │ │ ├── AudioTool.kt │ │ │ ├── BlurTransformation.kt │ │ │ ├── ComposeExt.kt │ │ │ ├── DataStoreUtil.kt │ │ │ ├── MimeTypeExt.kt │ │ │ ├── MusicExt.kt │ │ │ └── StorageUtil.kt │ │ │ ├── viewModel │ │ │ ├── MusicSearchViewModel.kt │ │ │ ├── PlayBackViewModel.kt │ │ │ ├── PlaylistViewModel.kt │ │ │ ├── SelectSongViewModel.kt │ │ │ ├── SettingViewModel.kt │ │ │ └── SongViewModel.kt │ │ │ ├── visualiser │ │ │ ├── FFTAudioProcessor.kt │ │ │ ├── FFTListener.java │ │ │ ├── MusicVisualiser.kt │ │ │ ├── VisualizerDrawableManager.kt │ │ │ └── drawable │ │ │ │ ├── BlurVisualiser.kt │ │ │ │ ├── CircleVisualiser.kt │ │ │ │ ├── LineVisualiser.kt │ │ │ │ ├── RainVisualiserDrawable.kt │ │ │ │ └── VisualiserDrawable.kt │ │ │ └── widget │ │ │ ├── BottomPlayerBar.kt │ │ │ ├── EqSettingView.kt │ │ │ ├── PlaylistItem.kt │ │ │ ├── SongControllerPanel.kt │ │ │ ├── SongItem.kt │ │ │ ├── VisualizerView.kt │ │ │ ├── VisualizerWidget.kt │ │ │ └── audio_seekbar │ │ │ ├── AmplitudeType.kt │ │ │ ├── AudioSeekBar.kt │ │ │ ├── BrushExt.kt │ │ │ ├── Ext.kt │ │ │ └── WaveformAlignment.kt │ └── res │ │ ├── drawable │ │ ├── default_cover.jpg │ │ ├── ic_app.xml │ │ ├── ic_audio_line.xml │ │ ├── ic_dvd.xml │ │ ├── ic_new.xml │ │ ├── ic_next.xml │ │ ├── ic_pause.xml │ │ ├── ic_play.xml │ │ ├── ic_playlist_remove.xml │ │ ├── ic_plus.xml │ │ ├── ic_pre.xml │ │ └── ic_remove.xml │ │ ├── mipmap-anydpi-v26 │ │ └── ic_launcher.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_background.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_monochrome.png │ │ ├── mipmap │ │ └── default_cover.jpg │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── me │ └── spica27 │ └── spicamusic │ └── ExampleUnitTest.kt ├── build.gradle ├── decoder_flac ├── README.md ├── build.gradle ├── build │ ├── .transforms │ │ ├── 1d03ef9414a0bc197928d8d7193223af │ │ │ └── transformed │ │ │ │ └── bundleLibRuntimeToDirRelease │ │ │ │ └── bundleLibRuntimeToDirRelease_dex │ │ │ │ └── androidx │ │ │ │ └── media3 │ │ │ │ └── decoder │ │ │ │ └── flac │ │ │ │ ├── FlacBinarySearchSeeker$1.dex │ │ │ │ └── FlacExtractor.dex │ │ ├── 31546844da043d1214a3e0db40582720 │ │ │ └── results.bin │ │ ├── 9f269d71b886e2dd8426bc057a192b3c │ │ │ └── transformed │ │ │ │ └── bundleLibRuntimeToDirDebug │ │ │ │ └── androidx │ │ │ │ └── media3 │ │ │ │ └── decoder │ │ │ │ └── flac │ │ │ │ ├── FlacDecoderJni$FlacFrameDecodeException.dex │ │ │ │ ├── FlacDecoderJni.dex │ │ │ │ └── package-info.dex │ │ ├── a5958f52ec8a25958ae333556be3547d │ │ │ └── transformed │ │ │ │ └── bundleLibRuntimeToDirDebug │ │ │ │ └── androidx │ │ │ │ └── media3 │ │ │ │ └── decoder │ │ │ │ └── flac │ │ │ │ └── FlacBinarySearchSeeker.dex │ │ ├── e64e43cc4410128874ea299d3e4cc1ae │ │ │ └── transformed │ │ │ │ └── bundleLibRuntimeToDirDebug │ │ │ │ └── androidx │ │ │ │ └── media3 │ │ │ │ └── decoder │ │ │ │ └── flac │ │ │ │ └── FlacBinarySearchSeeker.dex │ │ ├── eeb65c14c090fc702a9eb4b02b51640b │ │ │ └── transformed │ │ │ │ └── bundleLibRuntimeToDirDebug │ │ │ │ └── androidx │ │ │ │ └── media3 │ │ │ │ └── decoder │ │ │ │ └── flac │ │ │ │ └── FlacBinarySearchSeeker$FlacTimestampSeeker.dex │ │ └── fb458d17bb1ab52cb1b4e8f8aa3aa7c8 │ │ │ └── transformed │ │ │ └── bundleLibRuntimeToDirDebug │ │ │ └── androidx │ │ │ └── media3 │ │ │ └── decoder │ │ │ └── flac │ │ │ └── FlacDecoderJni.dex │ ├── intermediates │ │ ├── aapt_friendly_merged_manifests │ │ │ └── debug │ │ │ │ └── processDebugManifest │ │ │ │ └── aapt │ │ │ │ └── AndroidManifest.xml │ │ ├── compile_symbol_list │ │ │ └── release │ │ │ │ └── generateReleaseRFile │ │ │ │ └── R.txt │ │ ├── desugar_graph │ │ │ └── debugAndroidTest │ │ │ │ └── dexBuilderDebugAndroidTest │ │ │ │ └── out │ │ │ │ └── currentProject │ │ │ │ ├── dirs_bucket_0 │ │ │ │ └── graph.bin │ │ │ │ └── jar_21b782bae186444e85dc99b838ca35529d673440025a33f4918e6efb26fc32ea_bucket_1 │ │ │ │ └── graph.bin │ │ ├── incremental │ │ │ ├── debugAndroidTest-mergeJavaRes │ │ │ │ └── zip-cache │ │ │ │ │ ├── 4JlYajEDSkuwp6dx_XQjkFvUNE8= │ │ │ │ │ └── 98ai+Hnq3fTWBmflSOJdI6UZ6Ns= │ │ │ ├── debugAndroidTest │ │ │ │ └── mergeDebugAndroidTestResources │ │ │ │ │ └── merged.dir │ │ │ │ │ ├── values-az │ │ │ │ │ └── values-az.xml │ │ │ │ │ ├── values-es │ │ │ │ │ └── values-es.xml │ │ │ │ │ ├── values-fr │ │ │ │ │ └── values-fr.xml │ │ │ │ │ ├── values-ka │ │ │ │ │ └── values-ka.xml │ │ │ │ │ ├── values-th │ │ │ │ │ └── values-th.xml │ │ │ │ │ ├── values-tr │ │ │ │ │ └── values-tr.xml │ │ │ │ │ ├── values-ur │ │ │ │ │ └── values-ur.xml │ │ │ │ │ └── values-v24 │ │ │ │ │ └── values-v24.xml │ │ │ ├── packageDebugAndroidTest │ │ │ │ └── tmp │ │ │ │ │ └── debugAndroidTest │ │ │ │ │ └── zip-cache │ │ │ │ │ └── androidResources │ │ │ └── release │ │ │ │ └── packageReleaseResources │ │ │ │ └── compile-file-map.properties │ │ ├── merged_native_libs │ │ │ └── debug │ │ │ │ └── mergeDebugNativeLibs │ │ │ │ └── out │ │ │ │ └── lib │ │ │ │ └── arm64-v8a │ │ │ │ └── libflacJNI.so │ │ ├── merged_res │ │ │ └── debugAndroidTest │ │ │ │ └── mergeDebugAndroidTestResources │ │ │ │ ├── values-af_values-af.arsc.flat │ │ │ │ ├── values-hu_values-hu.arsc.flat │ │ │ │ ├── values-ms_values-ms.arsc.flat │ │ │ │ ├── values-or_values-or.arsc.flat │ │ │ │ ├── values-ro_values-ro.arsc.flat │ │ │ │ ├── values-si_values-si.arsc.flat │ │ │ │ ├── values-sk_values-sk.arsc.flat │ │ │ │ └── values-v24_values-v24.arsc.flat │ │ ├── merged_res_blame_folder │ │ │ └── debugAndroidTest │ │ │ │ └── mergeDebugAndroidTestResources │ │ │ │ └── out │ │ │ │ └── multi-v2 │ │ │ │ ├── values-bn.json │ │ │ │ ├── values-bs.json │ │ │ │ ├── values-en-rGB.json │ │ │ │ ├── values-iw.json │ │ │ │ ├── values-lo.json │ │ │ │ ├── values-lt.json │ │ │ │ ├── values-mk.json │ │ │ │ ├── values-pt.json │ │ │ │ ├── values-sv.json │ │ │ │ ├── values-th.json │ │ │ │ ├── values-uk.json │ │ │ │ └── values-zh-rCN.json │ │ ├── nested_resources_validation_report │ │ │ ├── debug │ │ │ │ └── generateDebugResources │ │ │ │ │ └── nestedResourcesValidationReport.txt │ │ │ └── debugAndroidTest │ │ │ │ └── generateDebugAndroidTestResources │ │ │ │ └── nestedResourcesValidationReport.txt │ │ ├── runtime_library_classes_dir │ │ │ ├── debug │ │ │ │ └── bundleLibRuntimeToDirDebug │ │ │ │ │ └── androidx │ │ │ │ │ └── media3 │ │ │ │ │ └── decoder │ │ │ │ │ └── flac │ │ │ │ │ ├── FlacExtractor.class │ │ │ │ │ └── package-info.class │ │ │ └── release │ │ │ │ └── bundleLibRuntimeToDirRelease │ │ │ │ └── androidx │ │ │ │ └── media3 │ │ │ │ └── decoder │ │ │ │ └── flac │ │ │ │ ├── FlacBinarySearchSeeker.class │ │ │ │ └── FlacDecoder.class │ │ └── runtime_library_classes_jar │ │ │ └── debug │ │ │ └── bundleLibRuntimeToJarDebug │ │ │ └── classes.jar │ └── tmp │ │ ├── compileDebugAndroidTestJavaWithJavac │ │ └── previous-compilation-data.bin │ │ └── compileDebugUnitTestJavaWithJavac │ │ └── previous-compilation-data.bin ├── proguard-rules.txt └── src │ ├── androidTest │ ├── AndroidManifest.xml │ └── java │ │ └── androidx │ │ └── media3 │ │ └── decoder │ │ └── flac │ │ ├── FlacExtractorSeekTest.java │ │ ├── FlacExtractorTest.java │ │ └── FlacPlaybackTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── androidx │ │ │ └── media3 │ │ │ └── decoder │ │ │ └── flac │ │ │ ├── FlacBinarySearchSeeker.java │ │ │ ├── FlacDecoder.java │ │ │ ├── FlacDecoderException.java │ │ │ ├── FlacDecoderJni.java │ │ │ ├── FlacExtractor.java │ │ │ ├── FlacLibrary.java │ │ │ ├── LibflacAudioRenderer.java │ │ │ └── package-info.java │ └── libs │ │ ├── arm64-v8a │ │ └── libflacJNI.so │ │ └── armeabi-v7a │ │ └── libflacJNI.so │ └── test │ ├── AndroidManifest.xml │ └── java │ └── androidx │ └── media3 │ └── decoder │ └── flac │ └── DefaultRenderersFactoryTest.java ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── img ├── Screenshot_20250621_231150.png ├── Screenshot_20250621_231231.png ├── Screenshot_20250621_231303.png └── Screenshot_20250621_231331.png ├── key.jks └── settings.gradle /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-java@v3 15 | with: 16 | distribution: temurin 17 | java-version: 11 18 | - uses: gradle/gradle-build-action@v2 19 | with: 20 | gradle-version: current 21 | arguments: assembleRelease 22 | - uses: r0adkll/sign-android-release@v1 23 | id: sign_app 24 | with: 25 | releaseDirectory: app/build/outputs/apk/release 26 | - run: mv ${{steps.sign_app.outputs.signedReleaseFile}} LittleGooseOffice_$GITHUB_REF_NAME.apk 27 | - uses: ncipollo/release-action@v1 28 | with: 29 | artifacts: "*.apk" 30 | token: ${{ github.token }} 31 | generateReleaseNotes: true 32 | -------------------------------------------------------------------------------- /.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 | local.properties 16 | /decoder_flac/build/ 17 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 116 | 117 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 杨为智 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 柠檬音乐【Compose版】 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 |

10 | 11 | ## 项目简介 12 | 13 | EXOPlayer+Compose实现的音乐播放器 14 | 15 | ## 库 16 | 17 | - [Jetpack Compose](https://developer.android.com/compose): Jetpack Compose 是一个现代化的工具包,用于构建原生 18 | Android 应用的用户界面。它简化了 UI 开发,使您能够更快地构建应用。 19 | - [Media3](https://github.com/androidx/media): Media3 是一个用于播放音频和视频的库,它提供了一个用于播放媒体的 20 | API 21 | 和一组用于管理媒体会话的类。 22 | - [Amplituda](https://github.com/lincollincol/Amplituda): 一个用于分析音频的库,它提供了一种简单的方法来获取音频的振幅。 23 | - [Noise](https://github.com/paramsen/noise): 一个通用的FFT计算库,用于计算音频的频谱。 24 | - [Flac](https://xiph.org/flac/changelog.html):开源的无损格式音频编解码器, 用于解码flac格式的音频文件。 25 | 26 | 最近要开始找工作了,更新随缘🌈 -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | id 'com.google.dagger.hilt.android' 5 | id 'kotlin-android' 6 | id 'kotlin-parcelize' 7 | id 'com.google.devtools.ksp' 8 | alias(libs.plugins.jetbrains.kotlin.serialization) 9 | alias(libs.plugins.compose.compiler) 10 | } 11 | 12 | android { 13 | namespace 'me.spica27.spicamusic' 14 | compileSdk 36 15 | 16 | defaultConfig { 17 | applicationId "me.spica27.spicamusic" 18 | minSdk 24 19 | targetSdk 35 20 | versionCode 2 21 | versionName "1.1.1" 22 | 23 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 24 | vectorDrawables { 25 | useSupportLibrary true 26 | } 27 | } 28 | 29 | signingConfigs { 30 | signingConfig { 31 | storeFile rootProject.file("key.jks") 32 | storePassword 'SPICa27' 33 | keyAlias 'wuqi' 34 | keyPassword 'SPICa27' 35 | } 36 | } 37 | 38 | lintOptions { 39 | checkReleaseBuilds false 40 | abortOnError false 41 | } 42 | 43 | buildTypes { 44 | release { 45 | minifyEnabled false 46 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 47 | signingConfig signingConfigs.signingConfig 48 | } 49 | debug { 50 | signingConfig signingConfigs.signingConfig 51 | } 52 | } 53 | compileOptions { 54 | sourceCompatibility JavaVersion.VERSION_1_8 55 | targetCompatibility JavaVersion.VERSION_1_8 56 | } 57 | kotlinOptions { 58 | jvmTarget = '1.8' 59 | } 60 | buildFeatures { 61 | compose true 62 | } 63 | buildFeatures { 64 | // aidl=true 65 | buildConfig = true 66 | } 67 | sourceSets { 68 | main { 69 | jniLibs.srcDirs = ['libs'] 70 | } 71 | } 72 | composeOptions { 73 | kotlinCompilerExtensionVersion '1.5.7' 74 | } 75 | // kapt { 76 | // correctErrorTypes = true 77 | // } 78 | packaging { 79 | resources { 80 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 81 | } 82 | } 83 | } 84 | 85 | dependencies { 86 | implementation fileTree(dir: "libs", include: ["*.jar"]) 87 | // implementation project(":extension-flac2120") 88 | implementation libs.androidx.core.ktx 89 | implementation libs.androidx.lifecycle.runtime.ktx 90 | implementation libs.androidx.activity.compose 91 | implementation platform(libs.androidx.compose.bom) 92 | implementation libs.androidx.ui 93 | implementation libs.androidx.ui.graphics 94 | implementation libs.androidx.ui.tooling.preview 95 | implementation libs.androidx.material3 96 | implementation libs.androidx.constraintlayout 97 | testImplementation libs.junit 98 | androidTestImplementation libs.androidx.junit 99 | androidTestImplementation libs.androidx.espresso.core 100 | androidTestImplementation platform(libs.androidx.compose.bom) 101 | androidTestImplementation libs.androidx.ui.test.junit4 102 | debugImplementation libs.androidx.ui.tooling 103 | debugImplementation libs.androidx.ui.test.manifest 104 | implementation(libs.coil.compose) 105 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1") 106 | 107 | // implementation 'com.github.EspoirX:StarrySky:v2.6.9' 108 | 109 | def room_version = "2.7.2" 110 | implementation("androidx.room:room-runtime:$room_version") 111 | ksp("androidx.room:room-compiler:$room_version") 112 | implementation("androidx.room:room-ktx:$room_version") 113 | 114 | /** hilt **/ 115 | implementation libs.hilt.android 116 | ksp libs.hilt.compiler 117 | implementation libs.androidx.hilt.navigation.compose 118 | 119 | implementation libs.androidx.datastore.preferences 120 | 121 | implementation libs.timber 122 | implementation libs.noise 123 | 124 | def accompanistVersion = "0.36.0" 125 | implementation(libs.accompanist.permissions) 126 | 127 | implementation project(":decoder_flac") 128 | 129 | implementation libs.kotlinx.serialization.core 130 | implementation libs.androidx.adaptive 131 | implementation libs.androidx.adaptive.layout 132 | implementation libs.androidx.adaptive.navigation 133 | implementation libs.androidx.lifecycle.viewmodel.navigation3 134 | implementation libs.androidx.navigation3.runtime 135 | implementation libs.androidx.navigation3.ui 136 | 137 | 138 | // 播放器 139 | def media3_version = "1.4.1" 140 | // For media playback using ExoPlayer 141 | implementation libs.androidx.media3.exoplayer 142 | // implementation 'com.google.android.exoplayer:exoplayer-core:2.X.X' 143 | implementation libs.androidx.media 144 | implementation 'com.github.lincollincol:amplituda:2.2.2' 145 | // DSP 处理 146 | } -------------------------------------------------------------------------------- /app/libs/arm64-v8a/libliquidfun.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/libs/arm64-v8a/libliquidfun.so -------------------------------------------------------------------------------- /app/libs/arm64-v8a/libliquidfun_jni.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/libs/arm64-v8a/libliquidfun_jni.so -------------------------------------------------------------------------------- /app/libs/libliquidfun.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/libs/libliquidfun.jar -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /app/src/androidTest/java/me/spica27/spicamusic/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic 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("me.spica27.spicamusic", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 48 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 65 | 66 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/App.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | import timber.log.Timber 6 | 7 | @HiltAndroidApp 8 | class App : Application() { 9 | override fun onCreate() { 10 | super.onCreate() 11 | instance = this 12 | Timber.plant(Timber.DebugTree()) 13 | // SingletonImageLoader.setSafe(factory = { 14 | // ImageLoader.Builder(this) 15 | // .crossfade(true) 16 | // .serviceLoaderEnabled(true) 17 | // .logger(DebugLogger(Logger.Level.Error)) 18 | // .components { 19 | // 20 | // } 21 | // .build() 22 | // }) 23 | } 24 | 25 | companion object { 26 | private lateinit var instance: App 27 | 28 | fun getInstance(): App { 29 | return instance 30 | } 31 | } 32 | init { 33 | System.loadLibrary("liquidfun") 34 | System.loadLibrary("liquidfun_jni") 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic 2 | 3 | import android.content.Intent 4 | import android.graphics.Color 5 | import android.os.Bundle 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.SystemBarStyle 8 | import androidx.activity.compose.setContent 9 | import androidx.activity.enableEdgeToEdge 10 | import androidx.lifecycle.lifecycleScope 11 | import dagger.hilt.android.AndroidEntryPoint 12 | import kotlinx.coroutines.flow.collectLatest 13 | import kotlinx.coroutines.launch 14 | import linc.com.amplituda.Amplituda 15 | import me.spica27.spicamusic.service.MusicService 16 | import me.spica27.spicamusic.ui.AppMain 17 | import me.spica27.spicamusic.utils.DataStoreUtil 18 | import javax.inject.Inject 19 | 20 | @AndroidEntryPoint 21 | class MainActivity : ComponentActivity() { 22 | 23 | 24 | 25 | 26 | @Inject 27 | lateinit var dataStoreUtil: DataStoreUtil 28 | 29 | 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | startService(Intent(this, MusicService::class.java)) 33 | setContent { 34 | AppMain() 35 | } 36 | lifecycleScope.launch { 37 | dataStoreUtil.getForceDarkTheme.collectLatest { 38 | if (it) { 39 | enableEdgeToEdge( 40 | statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), 41 | navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) 42 | ) 43 | } else { 44 | enableEdgeToEdge( 45 | statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT,Color.TRANSPARENT), 46 | navigationBarStyle = SystemBarStyle.light(Color.TRANSPARENT,Color.TRANSPARENT) 47 | ) 48 | } 49 | } 50 | } 51 | } 52 | 53 | 54 | @Inject 55 | lateinit var amplituda: Amplituda 56 | 57 | override fun onDestroy() { 58 | super.onDestroy() 59 | amplituda.clearCache() 60 | } 61 | 62 | } 63 | 64 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/db/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import me.spica27.spicamusic.db.dao.PlaylistDao 6 | import me.spica27.spicamusic.db.dao.SongDao 7 | import me.spica27.spicamusic.db.entity.Playlist 8 | import me.spica27.spicamusic.db.entity.PlaylistSongCrossRef 9 | import me.spica27.spicamusic.db.entity.Song 10 | 11 | 12 | /** 13 | * 数据库 14 | */ 15 | @Database( 16 | entities = [Song::class, Playlist::class, PlaylistSongCrossRef::class], 17 | version = 6, 18 | exportSchema = false 19 | ) 20 | abstract class AppDatabase : RoomDatabase() { 21 | 22 | abstract fun songDao(): SongDao 23 | 24 | abstract fun playlistDao(): PlaylistDao 25 | 26 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/db/dao/PlaylistDao.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.db.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import kotlinx.coroutines.flow.Flow 9 | import me.spica27.spicamusic.db.entity.Playlist 10 | import me.spica27.spicamusic.db.entity.PlaylistSongCrossRef 11 | import me.spica27.spicamusic.db.entity.PlaylistWithSongs 12 | import me.spica27.spicamusic.db.entity.Song 13 | 14 | 15 | @Dao 16 | interface PlaylistDao { 17 | 18 | 19 | @Transaction 20 | @Query("SELECT * FROM Playlist") 21 | fun getPlaylistsWithSongs(): List 22 | 23 | @Transaction 24 | @Query("SELECT * FROM Playlist") 25 | fun getAllPlaylist(): Flow> 26 | 27 | 28 | @Query("SELECT * FROM Playlist WHERE playlistId == :playlistId") 29 | fun getPlayListByIdFlow(playlistId: Long): Flow 30 | 31 | @Query("SELECT * FROM Playlist WHERE playlistId == :playlistId") 32 | fun getPlayListById(playlistId: Long): Playlist 33 | 34 | @Query("update Playlist set playlistName = :newName where playlistId = :playlistId") 35 | suspend fun renamePlaylist(playlistId: Long, newName: String) 36 | 37 | 38 | @Transaction 39 | @Query("SELECT * FROM Playlist WHERE playlistId == :playlistId") 40 | fun getPlaylistsWithSongsWithPlayListId(playlistId: Long): PlaylistWithSongs? 41 | 42 | 43 | @Transaction 44 | @Query("SELECT * FROM Playlist WHERE playlistId == :playlistId") 45 | fun getPlaylistsWithSongsWithPlayListIdFlow(playlistId: Long): Flow 46 | 47 | 48 | @Query("SELECT * FROM Song WHERE songId IN (SELECT songId FROM PlaylistSongCrossRef WHERE playlistId == :playlistId)") 49 | fun getSongsByPlaylistIdFlow(playlistId: Long): Flow> 50 | 51 | @Query("SELECT * FROM Song WHERE songId IN (SELECT songId FROM PlaylistSongCrossRef WHERE playlistId == :playlistId)") 52 | fun getSongsByPlaylistId(playlistId: Long): List 53 | 54 | @Transaction 55 | @Query("DELETE FROM playlist WHERE playlistId ==:playlistId") 56 | fun deleteList(playlistId: Long) 57 | 58 | @Transaction 59 | @Insert 60 | suspend fun insertListItems(songs: List) 61 | 62 | @Transaction 63 | @Insert 64 | suspend fun insertListItem(songs: PlaylistSongCrossRef) 65 | 66 | @Transaction 67 | @Insert 68 | suspend fun insertPlaylist(list: Playlist) 69 | 70 | @Query("DELETE FROM playlist WHERE playlistId == :playlistId") 71 | suspend fun deleteById(playlistId: Long) 72 | 73 | @Query("UPDATE playlist SET playlistName = :newName WHERE playlistId == :playlistId") 74 | suspend fun saveNewNameById(playlistId: Long, newName: String) 75 | 76 | @Transaction 77 | @Delete 78 | suspend fun deleteListItem(song: PlaylistSongCrossRef) 79 | 80 | 81 | @Transaction 82 | @Delete 83 | suspend fun deleteListItems(songs: List) 84 | 85 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/db/dao/SongDao.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.db.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import androidx.room.Transaction 9 | import kotlinx.coroutines.flow.Flow 10 | import me.spica27.spicamusic.db.entity.Song 11 | 12 | 13 | @Dao 14 | interface SongDao { 15 | 16 | @Insert(onConflict = OnConflictStrategy.REPLACE) 17 | suspend fun insert(song: Song) 18 | 19 | 20 | // 切换是否收藏 21 | @Query("UPDATE song SET `like` = (CASE WHEN`LIKE` == 1 THEN 0 ELSE 1 END) WHERE( songId == :id)") 22 | suspend fun toggleLike(id: Long) 23 | 24 | @Insert(onConflict = OnConflictStrategy.IGNORE) 25 | suspend fun insert(songs: List) 26 | 27 | @Insert(onConflict = OnConflictStrategy.IGNORE) 28 | fun insertSync(songs: List) 29 | 30 | @Query("UPDATE song SET playTimes = (playTimes + 1) WHERE songId == :id") 31 | suspend fun addPlayTime(id: Long) 32 | 33 | 34 | @Query("DELETE FROM song WHERE mediaStoreId NOT IN (:mediaIds)") 35 | suspend fun deleteSongsNotInList(mediaIds: List) 36 | 37 | @Query( 38 | "SELECT * FROM song WHERE displayName LIKE '%' ||:name|| '%'" + 39 | "OR artist LIKE '%' ||:name|| '%'" 40 | ) 41 | fun getsSongsFromName(name: String): Flow> 42 | 43 | 44 | @Query( 45 | "SELECT * FROM song WHERE displayName LIKE '%' ||:name|| '%'" + 46 | "OR artist LIKE '%' ||:name|| '%'" 47 | ) 48 | fun getsSongsFromNameSync(name: String): List 49 | 50 | @Query("SELECT * FROM song WHERE songId == :id") 51 | fun getSongWithId(id: Long): Song 52 | 53 | @Query("SELECT * FROM song WHERE mediaStoreId == :id") 54 | fun getSongWithMediaStoreId(id: Long): Song? 55 | 56 | @Query("SELECT * FROM song WHERE songId == :id") 57 | fun getSongFlowWithId(id: Long): Flow 58 | 59 | @Query("SELECT `like` FROM song WHERE songId == :id") 60 | fun getSongIsLikeFlowWithId(id: Long): Flow 61 | 62 | @Query("SELECT * FROM song") 63 | fun getAll(): Flow> 64 | 65 | @Query("SELECT * FROM song") 66 | fun getAllSync(): List 67 | 68 | @Query("SELECT * FROM song WHERE `like` == 1") 69 | fun getAllLikeSong(): Flow> 70 | 71 | 72 | @Query("DELETE FROM song") 73 | suspend fun deleteAll() 74 | 75 | @Query("DELETE FROM song") 76 | fun deleteAllSync() 77 | 78 | 79 | @Query("SELECT * FROM song WHERE songId NOT IN (SELECT songId FROM playlistsongcrossref WHERE playlistId = :playlistId)") 80 | fun getSongsNotInPlayList(playlistId: Long): Flow> 81 | 82 | @Delete 83 | suspend fun delete(song: Song) 84 | 85 | @Delete 86 | suspend fun delete(song: List) 87 | 88 | @Transaction 89 | suspend fun updateSongs(songs: List) { 90 | val songIds = songs.map { it.mediaStoreId } 91 | deleteSongsNotInList(songIds) 92 | insert(songs) 93 | } 94 | 95 | 96 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/db/entity/Playlist.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.db.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | import java.util.* 6 | 7 | @Entity 8 | data class Playlist( 9 | @PrimaryKey(autoGenerate = true) 10 | var playlistId: Long? = null, 11 | var playlistName: String = "自定义播放列表${Date().time}", 12 | var cover: String? = null 13 | ) { 14 | 15 | // @Ignore 16 | // constructor():this(0,"",null) 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/db/entity/PlaylistSongCrossRef.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.db.entity 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.Junction 7 | import androidx.room.Relation 8 | 9 | 10 | @Entity(primaryKeys = ["playlistId", "songId"]) 11 | data class PlaylistSongCrossRef( 12 | val playlistId: Long, 13 | @ColumnInfo(index = true) 14 | val songId: Long 15 | ) 16 | 17 | 18 | data class PlaylistWithSongs( 19 | @Embedded val playlist: Playlist, 20 | @Relation( 21 | parentColumn = "playlistId", 22 | entityColumn = "songId", 23 | associateBy = Junction(PlaylistSongCrossRef::class) 24 | ) 25 | val songs: List 26 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/db/entity/Song.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.db.entity 2 | 3 | import android.net.Uri 4 | import android.os.Parcelable 5 | import androidx.room.ColumnInfo 6 | import androidx.room.Entity 7 | import androidx.room.Index 8 | import androidx.room.PrimaryKey 9 | import me.spica27.spicamusic.utils.toAudioUri 10 | import me.spica27.spicamusic.utils.toCoverUri 11 | 12 | @kotlinx.parcelize.Parcelize 13 | @Entity( 14 | indices = [ 15 | Index("displayName"), 16 | Index("mediaStoreId", unique = true), 17 | ] 18 | ) 19 | data class Song constructor( 20 | @ColumnInfo(index = true) 21 | @PrimaryKey(autoGenerate = true) 22 | var songId: Long? = null, 23 | var mediaStoreId: Long, 24 | var path: String, 25 | var displayName: String, 26 | var artist: String, 27 | var size: Long, 28 | var like: Boolean, 29 | val duration: Long, 30 | var sort: Int, 31 | var playTimes: Int, 32 | var lastPlayTime: Int, 33 | var mimeType: String, 34 | var albumId: Long, 35 | var sampleRate: Int, // 采样率 36 | var bitRate: Int, // 比特率 37 | var channels: Int, // 声道数 38 | var digit: Int // 位深度 39 | ) : Parcelable { 40 | 41 | fun getFormatMimeType(): String { 42 | if (mimeType.contains("audio/")) { 43 | return mimeType.replace("audio/", "") 44 | } 45 | return mimeType 46 | } 47 | 48 | fun getCoverUri(): Uri { 49 | return albumId.toCoverUri() 50 | } 51 | 52 | fun getSongUri(): Uri { 53 | return mediaStoreId.toAudioUri() 54 | } 55 | 56 | override fun equals(other: Any?): Boolean { 57 | if (this === other) return true 58 | if (javaClass != other?.javaClass) return false 59 | 60 | other as Song 61 | 62 | if (songId != other.songId) return false 63 | if (mediaStoreId != other.mediaStoreId) return false 64 | if (path != other.path) return false 65 | if (displayName != other.displayName) return false 66 | if (artist != other.artist) return false 67 | if (size != other.size) return false 68 | if (like != other.like) return false 69 | if (duration != other.duration) return false 70 | if (sort != other.sort) return false 71 | if (playTimes != other.playTimes) return false 72 | if (lastPlayTime != other.lastPlayTime) return false 73 | 74 | return true 75 | } 76 | 77 | override fun hashCode(): Int { 78 | var result = songId?.hashCode() ?: 0 79 | result = 31 * result + mediaStoreId.hashCode() 80 | result = 31 * result + path.hashCode() 81 | result = 31 * result + displayName.hashCode() 82 | result = 31 * result + artist.hashCode() 83 | result = 31 * result + size.hashCode() 84 | result = 31 * result + like.hashCode() 85 | result = 31 * result + duration.hashCode() 86 | result = 31 * result + sort 87 | result = 31 * result + playTimes 88 | result = 31 * result + lastPlayTime 89 | return result 90 | } 91 | 92 | override fun toString(): String { 93 | return "Song(songId=$songId, mediaStoreId=$mediaStoreId, path='$path', displayName='$displayName', artist='$artist', size=$size, like=$like, duration=$duration, sort=$sort, playTimes=$playTimes, lastPlayTime=$lastPlayTime)" 94 | } 95 | 96 | 97 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/BandProcessor.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | 4 | import timber.log.Timber 5 | import kotlin.math.PI 6 | import kotlin.math.abs 7 | import kotlin.math.cos 8 | import kotlin.math.pow 9 | import kotlin.math.sqrt 10 | import kotlin.math.tan 11 | 12 | class BandProcessor(val band: NyquistBand, val sampleRate: Int, val channelCount: Int, val referenceGain: Double) { 13 | 14 | private val G0 = referenceGain.fromDb() 15 | private val GB = band.bandwidthGain.fromDb() 16 | private val G1 = band.gain.fromDb() 17 | 18 | private val xHist = Array(channelCount) { FloatArray(2) { 0f } } 19 | private val yHist = Array(channelCount) { FloatArray(2) { 0f } } 20 | 21 | private val beta = tan((band.bandwidth / 2.0) * PI / (sampleRate / 2.0)) * (sqrt(abs((GB.pow(2)) - (G0.pow(2)))) / sqrt(abs((G1.pow(2)) - (GB.pow(2))))) 22 | 23 | private val a1 = -2.0 * cos(band.centerFrequency * PI / (sampleRate / 2.0)) / (1.0 + beta) 24 | private val a2 = (1.0 - beta) / (1.0 + beta) 25 | 26 | private val b0 = (G0 + (G1 * beta)) / (1.0 + beta) 27 | private val b1 = -2.0 * G0 * cos(band.centerFrequency * PI / (sampleRate / 2.0)) / (1.0 + beta) 28 | private val b2 = (G0 - (G1 * beta)) / (1.0 + beta) 29 | 30 | init { 31 | if (band.gain > 0) { 32 | // Boost 33 | if (band.bandwidthGain < referenceGain || band.gain < band.bandwidthGain) { 34 | throw IllegalArgumentException("Invalid parameters. Boost gain ($band.gain) must be greater than bandwidth gain ($band.bandwidthGain), which must be greater than reference ($referenceGain)") 35 | } 36 | } else if (band.gain < 0) { 37 | // Cut 38 | if (band.bandwidthGain > referenceGain || band.gain > band.bandwidthGain) { 39 | throw IllegalArgumentException("Invalid parameters. Cut gain ($band.gain) must be less than bandwidth gain ($band.bandwidthGain), which must be less than reference ($referenceGain)") 40 | } 41 | } 42 | } 43 | 44 | fun processSample(sample: Float, channelIndex: Int): Float { 45 | 46 | if (band.bandwidthGain == 0.0 && band.gain == 0.0) { 47 | return sample 48 | } 49 | 50 | if (channelIndex >= channelCount) { 51 | Timber.v("Invalid channel index") 52 | return sample 53 | } 54 | 55 | val adjustedSample = ( 56 | (b0 * sample) + 57 | (b1 * xHist[channelIndex][0]) + 58 | (b2 * xHist[channelIndex][1]) - 59 | (a1 * yHist[channelIndex][0]) - 60 | (a2 * yHist[channelIndex][1]) 61 | ).toFloat() 62 | 63 | xHist[channelIndex][1] = xHist[channelIndex][0] 64 | xHist[channelIndex][0] = sample 65 | 66 | yHist[channelIndex][1] = yHist[channelIndex][0] 67 | yHist[channelIndex][0] = adjustedSample 68 | 69 | return adjustedSample 70 | } 71 | 72 | fun reset() { 73 | for (i in 0 until channelCount) { 74 | xHist[i] = FloatArray(2) { 0f } 75 | yHist[i] = FloatArray(2) { 0f } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/ByteUtils.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | import java.nio.ByteBuffer 4 | 5 | object ByteUtils { 6 | 7 | fun ByteBuffer.getInt24(): Int { 8 | val sample = this.getInt(position() + 2) shl 16 or (this.getInt(position() + 1) and 0xFF shl 8) or (this.getInt(position()) and 0xFF) 9 | position(position() + 3) 10 | return sample 11 | } 12 | 13 | fun ByteBuffer.putInt24(sample: Int): ByteBuffer { 14 | putInt(sample and 0xFF) 15 | putInt(sample ushr 8 and 0xFF) 16 | putInt(sample shr 16) 17 | return this 18 | } 19 | 20 | const val Int24_MIN_VALUE = -8388608 21 | const val Int24_MAX_VALUE = 8388607 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/Equalizer.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | import androidx.annotation.StringRes 4 | import me.spica27.spicamusic.R 5 | 6 | object Equalizer { 7 | 8 | val centerFrequency = intArrayOf( 9 | 32, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000 10 | ) 11 | 12 | object Presets { 13 | 14 | sealed class Preset( 15 | val name: String, 16 | @StringRes val nameResId: Int, 17 | val bands: List 18 | ) { 19 | 20 | object Flat : Preset( 21 | "Flat", 22 | R.string.eq_preset_flat, 23 | centerFrequency.map { 24 | EqualizerBand(it, 0.0).toNyquistBand() 25 | } 26 | ) 27 | 28 | object Custom : Preset( 29 | "Custom", 30 | R.string.eq_preset_custom, 31 | listOf( 32 | EqualizerBand(32, 3.0).toNyquistBand(), 33 | EqualizerBand(63, 5.0).toNyquistBand(), 34 | EqualizerBand(125, 4.0).toNyquistBand(), 35 | EqualizerBand(250, -4.5).toNyquistBand(), 36 | EqualizerBand(500, -6.0).toNyquistBand(), 37 | EqualizerBand(1000, -7.0).toNyquistBand(), 38 | EqualizerBand(2000, -4.0).toNyquistBand(), 39 | EqualizerBand(4000, -3.0).toNyquistBand(), 40 | EqualizerBand(8000, 0.5).toNyquistBand(), 41 | EqualizerBand(16000, 1.0).toNyquistBand() 42 | ) 43 | ) 44 | 45 | object BassBoost : Preset( 46 | "Bass Boost", 47 | R.string.eq_preset_bass_boost, 48 | listOf( 49 | EqualizerBand(32, 6.0).toNyquistBand(), 50 | EqualizerBand(63, 5.0).toNyquistBand(), 51 | EqualizerBand(125, 4.0).toNyquistBand(), 52 | EqualizerBand(250, 3.0).toNyquistBand(), 53 | EqualizerBand(500, 2.0).toNyquistBand(), 54 | EqualizerBand(1000, 0.0).toNyquistBand(), 55 | EqualizerBand(2000, 0.0).toNyquistBand(), 56 | EqualizerBand(4000, 0.0).toNyquistBand(), 57 | EqualizerBand(8000, 0.0).toNyquistBand(), 58 | EqualizerBand(16000, 0.0).toNyquistBand() 59 | ) 60 | ) 61 | 62 | object BassReducer : Preset( 63 | "Bass Reduction", 64 | R.string.eq_preset_bass_reduce, 65 | listOf( 66 | EqualizerBand(32, -6.0).toNyquistBand(), 67 | EqualizerBand(63, -5.0).toNyquistBand(), 68 | EqualizerBand(125, -4.0).toNyquistBand(), 69 | EqualizerBand(250, -3.0).toNyquistBand(), 70 | EqualizerBand(500, -2.0).toNyquistBand(), 71 | EqualizerBand(1000, 0.0).toNyquistBand(), 72 | EqualizerBand(2000, 0.0).toNyquistBand(), 73 | EqualizerBand(4000, 0.0).toNyquistBand(), 74 | EqualizerBand(8000, 0.0).toNyquistBand(), 75 | EqualizerBand(16000, 0.0).toNyquistBand() 76 | ) 77 | ) 78 | 79 | object VocalBoost : Preset( 80 | "Vocal Boost", 81 | R.string.eq_preset_vocal_boost, 82 | listOf( 83 | EqualizerBand(32, -2.0).toNyquistBand(), 84 | EqualizerBand(63, -3.0).toNyquistBand(), 85 | EqualizerBand(125, -3.0).toNyquistBand(), 86 | EqualizerBand(250, 2.0).toNyquistBand(), 87 | EqualizerBand(500, 5.0).toNyquistBand(), 88 | EqualizerBand(1000, 5.0).toNyquistBand(), 89 | EqualizerBand(2000, 4.0).toNyquistBand(), 90 | EqualizerBand(4000, 3.0).toNyquistBand(), 91 | EqualizerBand(8000, 0.0).toNyquistBand(), 92 | EqualizerBand(16000, -2.0).toNyquistBand() 93 | ) 94 | ) 95 | 96 | object VocalReducer : Preset( 97 | "Vocal Reduction", 98 | R.string.eq_preset_vocal_Reduce, 99 | listOf( 100 | EqualizerBand(32, 2.0).toNyquistBand(), 101 | EqualizerBand(63, 3.0).toNyquistBand(), 102 | EqualizerBand(125, 3.0).toNyquistBand(), 103 | EqualizerBand(250, -2.0).toNyquistBand(), 104 | EqualizerBand(500, -5.0).toNyquistBand(), 105 | EqualizerBand(1000, -5.0).toNyquistBand(), 106 | EqualizerBand(2000, -4.0).toNyquistBand(), 107 | EqualizerBand(4000, -3.0).toNyquistBand(), 108 | EqualizerBand(8000, -0.0).toNyquistBand(), 109 | EqualizerBand(16000, 2.0).toNyquistBand() 110 | ) 111 | ) 112 | 113 | override fun equals(other: Any?): Boolean { 114 | if (this === other) return true 115 | if (javaClass != other?.javaClass) return false 116 | 117 | other as Preset 118 | 119 | if (name != other.name) return false 120 | 121 | return true 122 | } 123 | 124 | override fun hashCode(): Int { 125 | return name.hashCode() 126 | } 127 | } 128 | 129 | val flat = Preset.Flat 130 | val custom = Preset.Custom 131 | val bassBoost = Preset.BassBoost 132 | val bassReducer = Preset.BassReducer 133 | val vocalBoost = Preset.VocalBoost 134 | val vocalReducer = Preset.VocalReducer 135 | 136 | val all = listOf(custom, flat, bassBoost, bassReducer, vocalBoost, vocalReducer) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/EqualizerAudioProcessor.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | 4 | import androidx.annotation.OptIn 5 | import androidx.media3.common.C 6 | import androidx.media3.common.audio.AudioProcessor 7 | import androidx.media3.common.audio.BaseAudioProcessor 8 | import androidx.media3.common.util.UnstableApi 9 | import me.spica27.spicamusic.dsp.ByteUtils.getInt24 10 | import me.spica27.spicamusic.dsp.ByteUtils.putInt24 11 | import timber.log.Timber 12 | import java.lang.Math.clamp 13 | import java.nio.ByteBuffer 14 | 15 | @OptIn(UnstableApi::class) 16 | class EqualizerAudioProcessor : BaseAudioProcessor() { 17 | 18 | private var bandProcessors = emptyList() 19 | 20 | 21 | // Maximum allowed gain/cut for each band 22 | val maxBandGain = 12 23 | 24 | val bands: ArrayList = ArrayList() 25 | 26 | 27 | /** 28 | * 设置曲线数据 29 | */ 30 | fun setBands(bands: List) { 31 | this.bands.clear() 32 | this.bands.addAll(bands) 33 | updateBandProcessors() 34 | } 35 | 36 | 37 | private fun updateBandProcessors() { 38 | if (outputAudioFormat.channelCount <= 0) { 39 | return 40 | } 41 | 42 | bandProcessors = bands.map { band -> 43 | BandProcessor( 44 | band.toNyquistBand(), 45 | sampleRate = outputAudioFormat.sampleRate, 46 | channelCount = outputAudioFormat.channelCount, 47 | referenceGain = 0.0 48 | ) 49 | }.toList() 50 | } 51 | 52 | override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { 53 | super.onConfigure(inputAudioFormat) 54 | 55 | if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT && inputAudioFormat.encoding != C.ENCODING_PCM_24BIT) { 56 | throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) 57 | } 58 | 59 | updateBandProcessors() 60 | 61 | return inputAudioFormat 62 | } 63 | 64 | override fun onFlush() { 65 | super.onFlush() 66 | 67 | Timber.v("onFlush() called") 68 | updateBandProcessors() 69 | } 70 | 71 | override fun queueInput(inputBuffer: ByteBuffer) { 72 | if (bands.isNotEmpty()) { 73 | val size = inputBuffer.remaining() 74 | val buffer = replaceOutputBuffer(size) 75 | 76 | when (outputAudioFormat.encoding) { 77 | C.ENCODING_PCM_16BIT -> { 78 | while (inputBuffer.hasRemaining()) { 79 | for (channelIndex in 0 until outputAudioFormat.channelCount) { 80 | val sample = inputBuffer.short 81 | var targetSample = sample.toFloat() 82 | for (band in bandProcessors) { 83 | targetSample = band.processSample(targetSample, channelIndex) 84 | } 85 | buffer.putShort( 86 | clamp( 87 | targetSample, 88 | Short.MIN_VALUE.toFloat(), 89 | Short.MAX_VALUE.toFloat() 90 | ).toInt().toShort() 91 | ) 92 | if (!inputBuffer.hasRemaining()) { 93 | break 94 | } 95 | } 96 | } 97 | } 98 | 99 | C.ENCODING_PCM_24BIT -> { 100 | while (inputBuffer.hasRemaining()) { 101 | for (channelIndex in 0 until outputAudioFormat.channelCount) { 102 | val sample = inputBuffer.getInt24() 103 | var targetSample = sample.toFloat() 104 | for (band in bandProcessors) { 105 | targetSample = band.processSample(targetSample, channelIndex) 106 | } 107 | buffer.putInt24( 108 | clamp( 109 | targetSample, 110 | ByteUtils.Int24_MIN_VALUE.toFloat(), 111 | ByteUtils.Int24_MAX_VALUE.toFloat() 112 | ).toInt() 113 | ) 114 | if (!inputBuffer.hasRemaining()) { 115 | break 116 | } 117 | } 118 | } 119 | } 120 | 121 | else -> { 122 | // No op 123 | } 124 | } 125 | inputBuffer.position(inputBuffer.limit()) 126 | buffer.flip() 127 | } else { 128 | val remaining = inputBuffer.remaining() 129 | if (remaining == 0) { 130 | return 131 | } 132 | replaceOutputBuffer(remaining).put(inputBuffer).flip() 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/EqualizerBand.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | 4 | import java.io.Serializable 5 | import kotlin.math.pow 6 | import kotlin.math.sqrt 7 | 8 | 9 | open class EqualizerBand(val centerFrequency: Int, var gain: Double) : Serializable { 10 | 11 | override fun equals(other: Any?): Boolean { 12 | if (this === other) return true 13 | if (javaClass != other?.javaClass) return false 14 | 15 | other as EqualizerBand 16 | 17 | if (centerFrequency != other.centerFrequency) return false 18 | if (gain != other.gain) return false 19 | 20 | return true 21 | } 22 | 23 | override fun hashCode(): Int { 24 | var result = centerFrequency 25 | result = 31 * result + gain.hashCode() 26 | return result 27 | } 28 | } 29 | 30 | fun EqualizerBand.toNyquistBand(): NyquistBand { 31 | 32 | val bandWidthGain = if (gain > 0) { 33 | sqrt((gain.pow(2) / 2)) // Boost 34 | } else { 35 | -sqrt((gain.pow(2) / 2)) // Cut 36 | } 37 | 38 | return NyquistBand(centerFrequency, (centerFrequency * 0.35f).toInt(), gain, bandWidthGain) 39 | } 40 | 41 | class NyquistBand(centerFrequency: Int, val bandwidth: Int, peakGain: Double, val bandwidthGain: Double) : 42 | EqualizerBand(centerFrequency, peakGain) 43 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/HighPassFilter.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | import kotlin.math.PI 4 | import kotlin.math.cos 5 | import kotlin.math.sin 6 | 7 | class HighPassFilter( 8 | val freq: Int, 9 | val sampleRate: Int, 10 | val channelCount: Int 11 | ) { 12 | 13 | private val q = 1.0 14 | private val omega = 2.0 * PI * freq / sampleRate 15 | private val sin = sin(omega) 16 | private val cos = cos(omega) 17 | private val alpha = sin / (2.0 * q) 18 | 19 | private val a0 = 1.0 + alpha 20 | private val a1 = -2.0 * cos 21 | private val a2 = 1.0 - alpha 22 | 23 | private val b0 = (1.0 + cos) / 2.0 24 | private val b1 = -(1.0 + cos) 25 | private val b2 = (1.0 + cos) / 2.0 26 | 27 | private val xHist = Array(channelCount) { FloatArray(2) { 0f } } 28 | private val yHist = Array(channelCount) { FloatArray(2) { 0f } } 29 | 30 | fun processSample(sample: Float, channelIndex: Int): Float { 31 | 32 | val adjustedSample = ( 33 | ((b0 / a0) * sample) + 34 | ((b1 / a0) * xHist[channelIndex][0]) + 35 | ((b2 / a0) * xHist[channelIndex][1]) - 36 | ((a1 / a0) * yHist[channelIndex][0]) - 37 | ((a2 / a0) * yHist[channelIndex][1]) 38 | ).toFloat() 39 | 40 | xHist[channelIndex][1] = xHist[channelIndex][0] 41 | xHist[channelIndex][0] = sample 42 | 43 | yHist[channelIndex][1] = yHist[channelIndex][0] 44 | yHist[channelIndex][0] = adjustedSample 45 | 46 | return adjustedSample 47 | } 48 | 49 | fun reset() { 50 | for (i in 0 until channelCount) { 51 | xHist[i] = FloatArray(2) { 0f } 52 | yHist[i] = FloatArray(2) { 0f } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/LowPassFilter.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | import kotlin.math.PI 4 | import kotlin.math.cos 5 | import kotlin.math.sin 6 | 7 | class LowPassFilter( 8 | val freq: Int, 9 | val sampleRate: Int, 10 | val channelCount: Int 11 | ) { 12 | 13 | private val q = 1.0 14 | private val omega = 2.0 * PI * freq / sampleRate 15 | private val sin = sin(omega) 16 | private val cos = cos(omega) 17 | private val alpha = sin / (2.0 * q) 18 | 19 | private val a0 = 1.0 + alpha 20 | private val a1 = -2.0 * cos 21 | private val a2 = 1.0 - alpha 22 | 23 | private val b0 = (1.0 - cos) / 2.0 24 | private val b1 = 1.0 - cos 25 | private val b2 = (1.0 - cos) / 2.0 26 | 27 | private val xHist = Array(channelCount) { FloatArray(2) { 0f } } 28 | private val yHist = Array(channelCount) { FloatArray(2) { 0f } } 29 | 30 | fun processSample(sample: Float, channelIndex: Int): Float { 31 | 32 | val adjustedSample = ( 33 | ((b0 / a0) * sample) + 34 | ((b1 / a0) * xHist[channelIndex][0]) + 35 | ((b2 / a0) * xHist[channelIndex][1]) - 36 | ((a1 / a0) * yHist[channelIndex][0]) - 37 | ((a2 / a0) * yHist[channelIndex][1]) 38 | ).toFloat() 39 | 40 | xHist[channelIndex][1] = xHist[channelIndex][0] 41 | xHist[channelIndex][0] = sample 42 | 43 | yHist[channelIndex][1] = yHist[channelIndex][0] 44 | yHist[channelIndex][0] = adjustedSample 45 | 46 | return adjustedSample 47 | } 48 | 49 | fun reset() { 50 | for (i in 0 until channelCount) { 51 | xHist[i] = FloatArray(2) { 0f } 52 | yHist[i] = FloatArray(2) { 0f } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/ReplayGainAudioProcessor.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | import androidx.annotation.OptIn 4 | import androidx.core.math.MathUtils 5 | import androidx.media3.common.C 6 | import androidx.media3.common.audio.AudioProcessor 7 | import androidx.media3.common.audio.BaseAudioProcessor 8 | import androidx.media3.common.util.UnstableApi 9 | import me.spica27.spicamusic.dsp.ByteUtils.Int24_MAX_VALUE 10 | import me.spica27.spicamusic.dsp.ByteUtils.Int24_MIN_VALUE 11 | import me.spica27.spicamusic.dsp.ByteUtils.getInt24 12 | import me.spica27.spicamusic.dsp.ByteUtils.putInt24 13 | import java.nio.ByteBuffer 14 | 15 | @OptIn(UnstableApi::class) 16 | class ReplayGainAudioProcessor(var preAmpGain: Double = 0.0) : BaseAudioProcessor() { 17 | 18 | 19 | // 歌曲的增益 20 | private var trackGain: Double? = null 21 | 22 | 23 | private val gain: Double 24 | get() = preAmpGain + (trackGain ?: 0.0) 25 | 26 | override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { 27 | if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT && 28 | inputAudioFormat.encoding != C.ENCODING_PCM_24BIT 29 | ) { 30 | throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) 31 | } 32 | return inputAudioFormat 33 | } 34 | 35 | override fun queueInput(inputBuffer: ByteBuffer) { 36 | 37 | 38 | if (gain != 0.0) { 39 | val size = inputBuffer.remaining() 40 | val buffer = replaceOutputBuffer(size) 41 | val delta = gain.fromDb() 42 | when (outputAudioFormat.encoding) { 43 | C.ENCODING_PCM_16BIT -> { 44 | while (inputBuffer.hasRemaining()) { 45 | val sample = inputBuffer.short 46 | val targetSample = MathUtils.clamp((sample * delta), Short.MIN_VALUE.toDouble(), Short.MAX_VALUE.toDouble()).toInt().toShort() 47 | buffer.putShort(targetSample) 48 | } 49 | } 50 | 51 | C.ENCODING_PCM_24BIT -> { 52 | while (inputBuffer.hasRemaining()) { 53 | val sample = inputBuffer.getInt24() 54 | val targetSample = MathUtils.clamp(sample * delta, Int24_MIN_VALUE.toDouble(), Int24_MAX_VALUE.toDouble()).toInt() 55 | buffer.putInt24(targetSample) 56 | } 57 | } 58 | 59 | else -> { 60 | // No op 61 | } 62 | } 63 | inputBuffer.position(inputBuffer.limit()) 64 | buffer.flip() 65 | } else { 66 | val remaining = inputBuffer.remaining() 67 | if (remaining == 0) { 68 | return 69 | } 70 | replaceOutputBuffer(remaining).put(inputBuffer).flip() 71 | } 72 | } 73 | 74 | companion object { 75 | const val maxPreAmpGain = 12 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/dsp/Utils.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.dsp 2 | 3 | import kotlin.math.log10 4 | import kotlin.math.pow 5 | 6 | fun Double.fromDb(): Double { 7 | return 10.0.pow(this / 20.0) 8 | } 9 | 10 | fun Double.toDb(): Double { 11 | return 20 * log10(this) 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/module/NavigationModule.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.module 2 | 3 | import dagger.Module 4 | import dagger.hilt.InstallIn 5 | import dagger.hilt.components.SingletonComponent 6 | 7 | @Module 8 | @InstallIn(SingletonComponent::class) 9 | internal abstract class NavigationModule { 10 | 11 | 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/module/PersistenceModule.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.module 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import me.spica27.spicamusic.db.AppDatabase 10 | import me.spica27.spicamusic.utils.DataStoreUtil 11 | import javax.inject.Singleton 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | object PersistenceModule { 16 | 17 | @Provides 18 | @Singleton 19 | fun provideAppDatabase( 20 | application: Application, 21 | ): AppDatabase { 22 | return Room 23 | .databaseBuilder( 24 | application, AppDatabase::class.java, 25 | "spica_music.db" 26 | ) 27 | .fallbackToDestructiveMigration() 28 | .build() 29 | } 30 | 31 | @Provides 32 | @Singleton 33 | fun provideDataStoreUtil(application: Application) = DataStoreUtil(application) 34 | 35 | @Provides 36 | @Singleton 37 | fun provideSongDao(appDatabase: AppDatabase) = appDatabase.songDao() 38 | 39 | @Provides 40 | @Singleton 41 | fun providePlaylistDao(appDatabase: AppDatabase) = appDatabase.playlistDao() 42 | 43 | 44 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/module/ToolModule.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.module 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import linc.com.amplituda.Amplituda 10 | import javax.inject.Singleton 11 | 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | object ToolModule { 16 | 17 | 18 | @Provides 19 | @Singleton 20 | fun provideAmplituda( 21 | application: Application, 22 | ): Amplituda { 23 | return Amplituda(application).apply { 24 | clearCache() 25 | setLogConfig(Log.ERROR,true) 26 | } 27 | } 28 | 29 | 30 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/playback/RepeatMode.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.playback 2 | 3 | 4 | // 循环方式 5 | enum class RepeatMode { 6 | 7 | NONE,// 顺序播放 8 | ALL, // 循环 9 | TRACK; // 单曲循环 10 | 11 | // 切换循环方式 12 | fun increment() = 13 | when (this) { 14 | NONE -> ALL 15 | ALL -> TRACK 16 | TRACK -> NONE 17 | } 18 | 19 | 20 | val icon: Int 21 | get() = 22 | when (this) { 23 | NONE -> -1 24 | ALL -> -1 25 | TRACK -> -1 26 | } 27 | 28 | 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/player/IPlayer.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.player 2 | 3 | import me.spica27.spicamusic.db.entity.Song 4 | 5 | 6 | interface IPlayer { 7 | 8 | 9 | fun loadSong(song: Song?, play: Boolean) 10 | 11 | fun getState(durationMs: Long): State 12 | 13 | fun seekTo(positionMs: Long) 14 | 15 | fun setPlaying(isPlaying: Boolean) 16 | 17 | 18 | class State( 19 | val isPlaying: Boolean, 20 | val currentPositionMs: Long, 21 | ) { 22 | 23 | 24 | companion object { 25 | 26 | fun from(isPlaying: Boolean, positionMs: Long) = 27 | State( 28 | isPlaying, 29 | positionMs 30 | ) 31 | } 32 | 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/player/Queue.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.player 2 | 3 | 4 | import me.spica27.spicamusic.db.entity.Song 5 | import java.util.concurrent.atomic.AtomicReference 6 | 7 | 8 | /** 9 | * 播放列表 10 | */ 11 | @Suppress("unused") 12 | class Queue { 13 | 14 | // 指针 15 | private val index = AtomicReference(0) 16 | 17 | // 播放列表 18 | private val heap = ArrayList() 19 | 20 | fun getPlayList(): List { 21 | return ArrayList(heap) 22 | } 23 | 24 | fun getIndex(): Int { 25 | return index.get() 26 | } 27 | 28 | 29 | fun currentSong(): Song? { 30 | return if (heap.isNotEmpty()) { 31 | heap[index.get()] 32 | } else { 33 | null 34 | } 35 | } 36 | 37 | 38 | fun clear() { 39 | synchronized(this) { 40 | heap.clear() 41 | index.set(0) 42 | } 43 | } 44 | 45 | fun remove(index: Int) { 46 | if (index < 0 || index >= heap.size) return 47 | synchronized(this) { 48 | heap.removeAt(index) 49 | 50 | if (index > heap.size - 1) { 51 | // 如果删除的是最后一个元素 避免越界 52 | this.index.set(heap.size - 1) 53 | } else if (index < this.index.get()) { 54 | // 如果删除的是当前播放的歌曲之前的歌曲 指针前移 55 | this.index.getAndUpdate { it - 1 } 56 | } 57 | } 58 | } 59 | 60 | fun add(song: Song): Boolean { 61 | synchronized(this) { 62 | if (heap.find { it.songId == song.songId } == null) { 63 | heap.add(0, song) 64 | return true 65 | } 66 | return false 67 | } 68 | } 69 | 70 | fun playNextSong(): Boolean { 71 | synchronized(this) { 72 | if (index.get() >= heap.size - 1) return false 73 | index.getAndUpdate { it + 1 } 74 | return true 75 | } 76 | } 77 | 78 | fun playPreSong(): Boolean { 79 | synchronized(this) { 80 | if (index.get() <= 0) return false 81 | index.getAndUpdate { it - 1 } 82 | return true 83 | } 84 | } 85 | 86 | fun reloadNewList(song: Song, songs: List) { 87 | synchronized(this) { 88 | heap.clear() 89 | heap.addAll(songs) 90 | this.index.set(0) 91 | heap.forEachIndexed { index, sg -> 92 | run { 93 | if (song.songId == sg.songId) { 94 | this.index.set(index) 95 | return 96 | } 97 | } 98 | } 99 | heap.add(0, song) 100 | index.set(0) 101 | } 102 | } 103 | 104 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/route/Routes.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.route 2 | 3 | import androidx.navigation3.runtime.NavKey 4 | import kotlinx.serialization.Serializable 5 | 6 | /// App的导航 7 | 8 | object Routes { 9 | 10 | 11 | @Serializable 12 | data object Splash : NavKey 13 | 14 | @Serializable 15 | data object Main : NavKey 16 | 17 | @Serializable 18 | data class AddSong(val playlistId: Long) : NavKey 19 | 20 | @Serializable 21 | data class PlaylistDetail(val playlistId: Long) : NavKey 22 | 23 | @Serializable 24 | data object Player : NavKey 25 | 26 | @Serializable 27 | data object SearchAll : NavKey 28 | 29 | 30 | @Serializable 31 | data object EQ : NavKey 32 | 33 | } 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/service/ForegroundManager.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.service 2 | 3 | import android.app.Service 4 | import androidx.core.app.ServiceCompat 5 | import timber.log.Timber 6 | 7 | /** 8 | * 用来给后台服务绑定前台通知的工具类 9 | */ 10 | class ForegroundManager(private val service: Service) { 11 | private var isForeground = false 12 | 13 | 14 | fun release() { 15 | tryStopForeground() 16 | } 17 | 18 | fun tryStartForeground(notification: ForegroundServiceNotification): Boolean { 19 | if (isForeground) { 20 | return false 21 | } 22 | 23 | Timber.d("Starting foreground state") 24 | service.startForeground(notification.code, notification.build()) 25 | isForeground = true 26 | return true 27 | } 28 | 29 | 30 | fun tryStopForeground(): Boolean { 31 | if (!isForeground) { 32 | // Nothing to do. 33 | return false 34 | } 35 | 36 | Timber.d("Stopping foreground state") 37 | ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE) 38 | isForeground = false 39 | return true 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/service/ForegroundServiceNotification.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.service 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import androidx.core.app.NotificationChannelCompat 6 | import androidx.core.app.NotificationCompat 7 | import androidx.core.app.NotificationManagerCompat 8 | 9 | abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) : 10 | NotificationCompat.Builder(context, info.id) { 11 | private val notificationManager = NotificationManagerCompat.from(context) 12 | 13 | init { 14 | val channel = 15 | NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW) 16 | .setName(context.getString(info.nameRes)) 17 | .setLightsEnabled(false) 18 | .setVibrationEnabled(false) 19 | .setShowBadge(false) 20 | .build() 21 | notificationManager.createNotificationChannel(channel) 22 | } 23 | 24 | 25 | abstract val code: Int 26 | 27 | 28 | fun post() { 29 | @Suppress("MissingPermission") notificationManager.notify(code, build()) 30 | } 31 | 32 | data class ChannelInfo(val id: String, @StringRes val nameRes: Int) 33 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/service/MuiscSearchWorker.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.service 2 | 3 | import android.app.Service 4 | import android.content.Intent 5 | import android.os.IBinder 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.launch 10 | import me.spica27.spicamusic.db.dao.SongDao 11 | import me.spica27.spicamusic.utils.AudioTool 12 | 13 | import timber.log.Timber 14 | import javax.inject.Inject 15 | 16 | 17 | @AndroidEntryPoint 18 | class RefreshMusicListService : Service() { 19 | 20 | @Inject 21 | lateinit var songDao: SongDao 22 | 23 | 24 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 25 | doWork() 26 | return START_STICKY 27 | } 28 | 29 | private fun doWork() { 30 | 31 | 32 | CoroutineScope(Dispatchers.IO).launch { 33 | try { 34 | val songs = AudioTool.getSongsFromPhone(applicationContext) 35 | Timber.tag("更新曲目成功").e("共${songs.size}首") 36 | songDao.updateSongs(songs) 37 | stopSelf() 38 | } catch (e: Exception) { 39 | Timber.tag("更新曲目错误").e(e) 40 | } 41 | } 42 | 43 | } 44 | 45 | 46 | override fun onBind(intent: Intent?): IBinder? = null 47 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/service/notification/NotificationComponent.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.service.notification 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.support.v4.media.MediaMetadataCompat 6 | import android.support.v4.media.session.MediaSessionCompat 7 | import androidx.annotation.DrawableRes 8 | import androidx.core.app.NotificationCompat 9 | import me.spica27.spicamusic.R 10 | import me.spica27.spicamusic.playback.RepeatMode 11 | import me.spica27.spicamusic.service.ForegroundServiceNotification 12 | import me.spica27.spicamusic.service.MusicService 13 | import me.spica27.spicamusic.utils.newBroadcastPendingIntent 14 | import me.spica27.spicamusic.utils.newMainPendingIntent 15 | 16 | @SuppressLint("RestrictedApi") 17 | class NotificationComponent(private val context: Context, sessionToken: MediaSessionCompat.Token) : 18 | ForegroundServiceNotification(context, CHANNEL_INFO) { 19 | 20 | 21 | override val code: Int 22 | get() = 0x102030 23 | 24 | init { 25 | setSmallIcon(R.drawable.ic_app) 26 | setCategory(NotificationCompat.CATEGORY_SERVICE) 27 | setShowWhen(false) 28 | setSilent(true) 29 | setContentIntent(context.newMainPendingIntent()) 30 | setVisibility(NotificationCompat.VISIBILITY_PUBLIC) 31 | 32 | addAction(buildAction(context, MusicService.ACTION_SKIP_PREV, R.drawable.ic_pre)) 33 | addAction(buildPlayPauseAction(context, true)) 34 | addAction(buildAction(context, MusicService.ACTION_SKIP_NEXT, R.drawable.ic_next)) 35 | 36 | setStyle( 37 | androidx.media.app 38 | .NotificationCompat 39 | .MediaStyle() 40 | .setMediaSession(sessionToken) 41 | .setShowActionsInCompactView(0, 1, 2) 42 | ) 43 | } 44 | 45 | 46 | fun updateMetadata(metadata: MediaMetadataCompat) { 47 | setLargeIcon(metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)) 48 | setContentTitle(metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE)) 49 | setContentText(metadata.getText(MediaMetadataCompat.METADATA_KEY_ARTIST)) 50 | setSubText(metadata.getText(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION)) 51 | } 52 | 53 | private fun buildPlayPauseAction( 54 | context: Context, 55 | isPlaying: Boolean 56 | ): NotificationCompat.Action { 57 | val drawableRes = 58 | if (isPlaying) { 59 | R.drawable.ic_pause 60 | } else { 61 | R.drawable.ic_play 62 | } 63 | return buildAction(context, MusicService.ACTION_PLAY_PAUSE, drawableRes) 64 | } 65 | 66 | private fun buildRepeatAction( 67 | context: Context, 68 | repeatMode: RepeatMode 69 | ): NotificationCompat.Action { 70 | return buildAction(context, MusicService.ACTION_INC_REPEAT_MODE, repeatMode.icon) 71 | } 72 | 73 | private fun buildAction(context: Context, actionName: String, @DrawableRes iconRes: Int) = 74 | NotificationCompat.Action.Builder( 75 | iconRes, actionName, context.newBroadcastPendingIntent(actionName) 76 | ) 77 | .build() 78 | 79 | 80 | fun updatePlaying(isPlaying: Boolean) { 81 | mActions[1] = buildPlayPauseAction(context, isPlaying) 82 | } 83 | 84 | 85 | private companion object { 86 | val CHANNEL_INFO = 87 | ChannelInfo( 88 | id = "me.spica27.spicamusic" + ".channel.PLAYBACK", 89 | nameRes = R.string.playing 90 | ) 91 | } 92 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | val AppTypography = Typography() 6 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/ui/AddSongScrren.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.ui 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.itemsIndexed 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.IconButton 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Scaffold 15 | import androidx.compose.material3.Text 16 | import androidx.compose.material3.TextButton 17 | import androidx.compose.material3.TopAppBar 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.collectAsState 20 | import androidx.compose.runtime.rememberCoroutineScope 21 | import androidx.compose.runtime.snapshots.SnapshotStateList 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.hilt.navigation.compose.hiltViewModel 25 | import androidx.navigation3.runtime.NavBackStack 26 | import kotlinx.coroutines.Dispatchers 27 | import kotlinx.coroutines.flow.combine 28 | import kotlinx.coroutines.launch 29 | import kotlinx.coroutines.withContext 30 | import me.spica27.spicamusic.viewModel.SelectSongViewModel 31 | import me.spica27.spicamusic.widget.SelectableSongItem 32 | 33 | 34 | /// 给歌单添加歌曲的页面 35 | @OptIn(ExperimentalMaterial3Api::class) 36 | @Composable 37 | fun AddSongScreen( 38 | viewModel: SelectSongViewModel = hiltViewModel(), 39 | navigator: NavBackStack, 40 | playlistId: Long 41 | ) { 42 | val coroutineScope = rememberCoroutineScope() 43 | viewModel.setPlaylistId(playlistId) 44 | Scaffold(topBar = { 45 | TopAppBar(title = { 46 | Text(text = "选择新增歌曲") 47 | }, navigationIcon = { 48 | // 返回按钮 49 | IconButton(onClick = { 50 | navigator.removeLastOrNull() 51 | }) { 52 | Icon(Icons.AutoMirrored.Default.KeyboardArrowLeft, contentDescription = "Back") 53 | } 54 | }, actions = { 55 | // 保存按钮 56 | TextButton(onClick = { 57 | coroutineScope.launch(Dispatchers.IO) { 58 | viewModel.addSongToPlaylist() 59 | withContext(Dispatchers.Main) { 60 | navigator.removeLastOrNull() 61 | } 62 | } 63 | }) { 64 | Text( 65 | "保存", style = MaterialTheme.typography.bodyLarge.copy( 66 | color = MaterialTheme.colorScheme.primary 67 | ) 68 | ) 69 | } 70 | }) 71 | }, content = { paddingValues -> 72 | Box( 73 | modifier = Modifier 74 | .fillMaxSize() 75 | .padding(paddingValues) 76 | ) { 77 | // 歌曲列表 78 | 79 | 80 | val listDataState = 81 | combine( 82 | viewModel.getAllSongsNotInPlaylist(), 83 | viewModel.selectedSongsIds 84 | ) { allSongs, selectIds -> 85 | allSongs.map { 86 | Pair(it, selectIds.contains(it.songId)) 87 | } 88 | } 89 | .collectAsState(initial = emptyList()) 90 | 91 | 92 | if (listDataState.value.isEmpty()) { 93 | Text("没有更多歌曲了", modifier = Modifier.align(Alignment.Center)) 94 | } else { 95 | LazyColumn(modifier = Modifier.fillMaxSize()) { 96 | itemsIndexed(listDataState.value, key = { _, item -> 97 | item.first.songId.toString() 98 | }) { _, song -> 99 | // 歌曲条目 100 | SelectableSongItem( 101 | song = song.first, 102 | selected = song.second, 103 | onToggle = { viewModel.toggleSongSelection(song.first.songId) }) 104 | } 105 | } 106 | } 107 | } 108 | }) 109 | } 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/ui/AppMain.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.ui 2 | 3 | import androidx.compose.animation.slideInHorizontally 4 | import androidx.compose.animation.slideOutHorizontally 5 | import androidx.compose.animation.togetherWith 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.platform.LocalContext 8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 9 | import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator 10 | import androidx.navigation3.runtime.entry 11 | import androidx.navigation3.runtime.entryProvider 12 | import androidx.navigation3.runtime.rememberNavBackStack 13 | import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator 14 | import androidx.navigation3.ui.NavDisplay 15 | import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator 16 | import me.spica27.spicamusic.route.Routes 17 | import me.spica27.spicamusic.theme.AppTheme 18 | import me.spica27.spicamusic.utils.DataStoreUtil 19 | import me.spica27.spicamusic.utils.sliderFromBottomRouteAnim 20 | 21 | 22 | @Composable 23 | fun AppMain() { 24 | val systemIsDark = DataStoreUtil().isForceDarkTheme 25 | val darkTheme = DataStoreUtil() 26 | .getForceDarkTheme.collectAsStateWithLifecycle(systemIsDark) 27 | .value 28 | val backStack = rememberNavBackStack(Routes.Splash) 29 | AppTheme( 30 | darkTheme = darkTheme, 31 | dynamicColor = false 32 | ) { 33 | NavDisplay( 34 | entryDecorators = listOf( 35 | rememberSceneSetupNavEntryDecorator(), 36 | rememberSavedStateNavEntryDecorator(), 37 | rememberViewModelStoreNavEntryDecorator() 38 | ), 39 | backStack = backStack, 40 | onBack = { backStack.removeLastOrNull() }, 41 | entryProvider = entryProvider { 42 | entry { key -> 43 | AddSongScreen(navigator = backStack, playlistId = key.playlistId) 44 | } 45 | entry { 46 | PlaylistDetailScreen( 47 | navigator = backStack, 48 | playlistId = it.playlistId 49 | ) 50 | } 51 | entry { MainScreen(navigator = backStack) } 52 | entry { SplashScreen(navigator = backStack) } 53 | entry( 54 | metadata = sliderFromBottomRouteAnim() 55 | ) { SearchAllScreen() } 56 | entry { 57 | EqScreen(navigator = backStack) 58 | } 59 | }, 60 | transitionSpec = { 61 | slideInHorizontally(initialOffsetX = { it }) togetherWith 62 | slideOutHorizontally(targetOffsetX = { -it }) 63 | }, 64 | popTransitionSpec = { 65 | slideInHorizontally(initialOffsetX = { -it }) togetherWith 66 | slideOutHorizontally(targetOffsetX = { it }) 67 | }, 68 | predictivePopTransitionSpec = { 69 | slideInHorizontally(initialOffsetX = { -it }) togetherWith 70 | slideOutHorizontally(targetOffsetX = { it }) 71 | } 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/ui/SplashScreen.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.ui 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.Scaffold 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.draw.alpha 15 | import androidx.compose.ui.unit.dp 16 | import androidx.navigation3.runtime.NavBackStack 17 | import kotlinx.coroutines.delay 18 | import me.spica27.spicamusic.route.Routes 19 | 20 | 21 | // Splash Screen 22 | @Composable 23 | fun SplashScreen(modifier: Modifier = Modifier, navigator: NavBackStack) { 24 | 25 | val visibilityState = remember { mutableStateOf(false) } 26 | 27 | // 透明度动画 28 | val textAlphaState = animateFloatAsState( 29 | targetValue = 30 | if (visibilityState.value) 1f else 0f, label = "textAlphaState" 31 | ) 32 | 33 | LaunchedEffect(Unit) { 34 | delay(1000) // 延迟2秒 35 | navigator.add(Routes.Main) 36 | } 37 | 38 | Scaffold { padding -> 39 | Box( 40 | modifier = modifier 41 | .fillMaxSize() 42 | .padding(padding), 43 | contentAlignment = androidx.compose.ui.Alignment.Center 44 | ) { 45 | Text( 46 | text = "Splash Screen", modifier = Modifier 47 | .alpha(textAlphaState.value) 48 | .align(alignment = androidx.compose.ui.Alignment.Center) 49 | .padding(16.dp) 50 | ) 51 | } 52 | 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/utils/ComposeExt.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.utils 2 | 3 | import androidx.compose.animation.EnterTransition 4 | import androidx.compose.animation.ExitTransition 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.animation.slideInVertically 7 | import androidx.compose.animation.slideOutVertically 8 | import androidx.compose.animation.togetherWith 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.interaction.MutableInteractionSource 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.composed 14 | import androidx.navigation3.ui.NavDisplay 15 | 16 | 17 | fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed { 18 | this.clickable( 19 | indication = null, 20 | interactionSource = remember { MutableInteractionSource() }) { 21 | onClick() 22 | } 23 | } 24 | 25 | fun sliderFromBottomRouteAnim() = NavDisplay.transitionSpec { 26 | slideInVertically( 27 | initialOffsetY = { it }, 28 | animationSpec = tween(450) 29 | ) togetherWith ExitTransition.KeepUntilTransitionsFinished 30 | } + NavDisplay.popTransitionSpec { 31 | EnterTransition.None togetherWith 32 | slideOutVertically( 33 | targetOffsetY = { it }, 34 | animationSpec = tween(450) 35 | ) 36 | } + NavDisplay.predictivePopTransitionSpec { 37 | EnterTransition.None togetherWith 38 | slideOutVertically( 39 | targetOffsetY = { it }, 40 | animationSpec = tween(450) 41 | ) 42 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/utils/DataStoreUtil.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.utils 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.booleanPreferencesKey 7 | import androidx.datastore.preferences.core.doublePreferencesKey 8 | import androidx.datastore.preferences.core.edit 9 | import androidx.datastore.preferences.core.intPreferencesKey 10 | import androidx.datastore.preferences.preferencesDataStore 11 | import kotlinx.coroutines.flow.Flow 12 | import kotlinx.coroutines.flow.distinctUntilChanged 13 | import kotlinx.coroutines.flow.first 14 | import kotlinx.coroutines.flow.map 15 | import kotlinx.coroutines.runBlocking 16 | import me.spica27.spicamusic.App 17 | import me.spica27.spicamusic.dsp.Equalizer 18 | import me.spica27.spicamusic.dsp.EqualizerBand 19 | import me.spica27.spicamusic.dsp.NyquistBand 20 | import me.spica27.spicamusic.dsp.toNyquistBand 21 | import timber.log.Timber 22 | 23 | // 字典工具类 24 | class DataStoreUtil(private val context: Context = App.getInstance()) { 25 | companion object { 26 | private val Context.dataStore: DataStore by preferencesDataStore("settings") 27 | 28 | // 夜间模式 29 | val FORCE_DARK_THEME = booleanPreferencesKey("force_dark_theme") 30 | 31 | // 自动扫描 32 | val AUTO_SCANNER = booleanPreferencesKey("auto_scanner") 33 | 34 | // 自动播放 35 | val AUTO_PLAY = booleanPreferencesKey("auto_play") 36 | 37 | // 响度增益 38 | val REPLAY_GAIN = intPreferencesKey("REPLAY_GAIN") 39 | 40 | 41 | } 42 | 43 | 44 | suspend fun saveEq( 45 | eq: List 46 | ) { 47 | context.dataStore.edit { preferences -> 48 | for (equalizerBand in eq) { 49 | Timber.tag("eq").d("saveEq: ${equalizerBand.centerFrequency} ${equalizerBand.gain}") 50 | preferences[doublePreferencesKey("EQ-${equalizerBand.centerFrequency}")] = 51 | equalizerBand.gain 52 | } 53 | } 54 | } 55 | 56 | fun getEqualizerBand(): Flow> { 57 | return context.dataStore.data.map { preferences -> 58 | Equalizer.centerFrequency.map { 59 | val gain: Double = preferences[doublePreferencesKey("EQ-${it}")] ?: 0.0 60 | EqualizerBand( 61 | it, 62 | gain 63 | ) 64 | } 65 | }.distinctUntilChanged() 66 | } 67 | 68 | 69 | fun getNyquistBand(): Flow> { 70 | return context.dataStore.data.map { preferences -> 71 | Equalizer.centerFrequency.map { 72 | val gain: Double = preferences[doublePreferencesKey("EQ-${it}")] ?: 0.0 73 | EqualizerBand( 74 | it, 75 | gain 76 | ).toNyquistBand() 77 | } 78 | }.distinctUntilChanged() 79 | } 80 | 81 | 82 | suspend fun saveReplayGain( 83 | replayGain: Int 84 | ) { 85 | context.dataStore.edit { preferences -> 86 | preferences[REPLAY_GAIN] = replayGain 87 | } 88 | } 89 | 90 | val getReplayGain: Flow 91 | get() = context.dataStore.data.map { preferences -> 92 | preferences[REPLAY_GAIN] ?: 0 93 | }.distinctUntilChanged() 94 | 95 | 96 | val isForceDarkTheme: Boolean 97 | get() = runBlocking { context.dataStore.data.first()[FORCE_DARK_THEME] ?: false } 98 | 99 | val getAutoPlay: Flow = context.dataStore.data 100 | .map { preferences -> 101 | preferences[AUTO_PLAY] ?: true 102 | }.distinctUntilChanged() 103 | 104 | suspend fun saveAutoPlay(value: Boolean) { 105 | context.dataStore.edit { preferences -> 106 | preferences[AUTO_PLAY] = value 107 | } 108 | } 109 | 110 | val getAutoScanner: Flow = context.dataStore.data 111 | .map { preferences -> 112 | preferences[AUTO_SCANNER] ?: true 113 | }.distinctUntilChanged() 114 | 115 | suspend fun saveAutoScanner(value: Boolean) { 116 | context.dataStore.edit { preferences -> 117 | preferences[AUTO_SCANNER] = value 118 | } 119 | } 120 | 121 | val getForceDarkTheme: Flow = context.dataStore.data 122 | .map { preferences -> 123 | preferences[FORCE_DARK_THEME] ?: false 124 | }.distinctUntilChanged() 125 | 126 | suspend fun saveForceDarkTheme(value: Boolean) { 127 | context.dataStore.edit { preferences -> 128 | preferences[FORCE_DARK_THEME] = value 129 | } 130 | } 131 | 132 | 133 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/utils/MimeTypeExt.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.utils 2 | 3 | import android.graphics.Color 4 | import androidx.annotation.ColorInt 5 | import androidx.media3.common.MimeTypes 6 | 7 | 8 | @ColorInt 9 | fun String.getColorFromMimeTypeString(): Int { 10 | return when (this) { 11 | MimeTypes.AUDIO_OGG -> Color.parseColor("#87e8de") 12 | MimeTypes.AUDIO_MP4 -> Color.parseColor("#95de64") 13 | MimeTypes.AUDIO_MPEG -> Color.parseColor("#b7eb8f") 14 | MimeTypes.AUDIO_FLAC -> Color.parseColor("#ffd666") 15 | else -> Color.parseColor("#d9d9d9") 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/utils/MusicExt.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.utils 2 | 3 | import android.text.format.DateUtils 4 | 5 | 6 | fun Long.formatDurationDs(isElapsed: Boolean = true) = dsToSecs().formatDurationSecs(isElapsed) 7 | fun Long.formatDurationSecs(isElapsed: Boolean = true): String { 8 | if (!isElapsed && this == 0L) { 9 | return "--:--" 10 | } 11 | var durationString = DateUtils.formatElapsedTime(this) 12 | if (durationString[0] == '0') { 13 | durationString = durationString.slice(1 until durationString.length) 14 | } 15 | return durationString 16 | } 17 | 18 | 19 | fun Long.msToDs() = floorDiv(100) 20 | 21 | 22 | fun Long.msToSecs() = floorDiv(1000) 23 | 24 | 25 | fun Long.dsToMs() = times(100) 26 | 27 | fun Long.dsToSecs() = floorDiv(10) 28 | 29 | 30 | fun Long.secsToMs() = times(1000) 31 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/viewModel/MusicSearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.viewModel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.combine 10 | import kotlinx.coroutines.flow.flowOn 11 | import kotlinx.coroutines.launch 12 | import me.spica27.spicamusic.db.dao.SongDao 13 | import me.spica27.spicamusic.db.entity.Song 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class MusicSearchViewModel @Inject constructor( 18 | val songDao: SongDao 19 | ) : ViewModel() { 20 | 21 | 22 | // 搜索关键字 23 | private val _searchKey = MutableStateFlow("") 24 | 25 | val searchKey: Flow 26 | get() = _searchKey 27 | 28 | // 是否过滤收藏的歌曲 29 | private val _filterNoLike = MutableStateFlow(false) 30 | 31 | val filterNoLike: Flow 32 | get() = _filterNoLike 33 | 34 | // 过滤过短的歌曲 35 | private val _filterShort = MutableStateFlow(false) 36 | 37 | val filterShort: Flow 38 | get() = _filterShort 39 | 40 | 41 | val songFlow:Flow> = combine( 42 | songDao.getAll(), 43 | _searchKey, 44 | _filterNoLike, 45 | _filterShort 46 | ) { songs, key, like, short -> 47 | songs 48 | .filter { 49 | (key.isEmpty() || it.displayName.contains(key, ignoreCase = true) || 50 | it.artist.contains( 51 | key, 52 | ignoreCase = true 53 | )) && 54 | (!like || it.like) && 55 | (!short || it.duration > 3000) 56 | } 57 | } 58 | .flowOn(Dispatchers.IO) 59 | 60 | // 收藏/不收藏歌曲 61 | fun toggleLike(song: Song) { 62 | viewModelScope.launch { 63 | songDao.toggleLike(song.songId ?: -1) 64 | } 65 | } 66 | 67 | 68 | fun onSearchKeyChange(newKey: String) { 69 | _searchKey.value = newKey 70 | } 71 | 72 | fun toggleFilterNoLike() { 73 | _filterNoLike.value = !_filterNoLike.value 74 | } 75 | 76 | fun toggleFilterShort() { 77 | _filterShort.value = !_filterShort.value 78 | } 79 | 80 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/viewModel/SelectSongViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.viewModel 2 | 3 | import androidx.annotation.OptIn 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import androidx.media3.common.util.UnstableApi 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.launch 12 | import me.spica27.spicamusic.db.dao.PlaylistDao 13 | import me.spica27.spicamusic.db.dao.SongDao 14 | import me.spica27.spicamusic.db.entity.PlaylistSongCrossRef 15 | import me.spica27.spicamusic.db.entity.Song 16 | import me.spica27.spicamusic.playback.PlaybackStateManager 17 | import javax.inject.Inject 18 | 19 | 20 | @HiltViewModel 21 | class SelectSongViewModel @Inject constructor( 22 | private val songDao: SongDao, 23 | private val playlistDao: PlaylistDao, 24 | ) : ViewModel() { 25 | 26 | private var playlistId: Long? = null 27 | 28 | fun getAllSongsNotInPlaylist() = songDao.getSongsNotInPlayList(playlistId ?: -1) 29 | 30 | fun getAllSongs() = songDao.getAll() 31 | 32 | private val selectIdsSet = hashSetOf() 33 | 34 | private val _selectedSongsIds = MutableStateFlow(hashSetOf()) 35 | 36 | val selectedSongsIds: Flow> 37 | get() = _selectedSongsIds 38 | 39 | fun setPlaylistId(playlistId: Long) { 40 | this.playlistId = playlistId 41 | } 42 | 43 | fun clearSelectedSongs() { 44 | selectIdsSet.clear() 45 | _selectedSongsIds.value = selectIdsSet.toHashSet() 46 | } 47 | 48 | // 切换歌曲选择状态 49 | fun toggleSongSelection(songId: Long?) { 50 | if (songId == null) return 51 | if (selectIdsSet.contains(songId)) { 52 | selectIdsSet.remove(songId) 53 | } else { 54 | selectIdsSet.add(songId) 55 | } 56 | _selectedSongsIds.value = selectIdsSet.toHashSet() 57 | } 58 | 59 | // 添加到当前播放列表 60 | @OptIn(UnstableApi::class) 61 | fun addSongToCurrentPlaylist(song: Song) { 62 | viewModelScope.launch(Dispatchers.Default) { 63 | PlaybackStateManager.getInstance().play(song) 64 | } 65 | } 66 | 67 | // 添加到播放列表 68 | suspend fun addSongToPlaylist() { 69 | playlistDao.insertListItems( 70 | _selectedSongsIds.value.map { songId -> PlaylistSongCrossRef(playlistId ?: 0, songId) }) 71 | } 72 | 73 | 74 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/viewModel/SettingViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.viewModel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.launch 8 | import me.spica27.spicamusic.utils.DataStoreUtil 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class SettingViewModel @Inject constructor( 13 | private val dataStoreUtil: DataStoreUtil 14 | ) : ViewModel() { 15 | 16 | val autoPlay = dataStoreUtil.getAutoPlay 17 | 18 | val autoScanner = dataStoreUtil.getAutoScanner 19 | 20 | val forceDarkTheme = dataStoreUtil.getForceDarkTheme 21 | 22 | fun saveAutoPlay(value: Boolean) { 23 | viewModelScope.launch(Dispatchers.IO) { 24 | dataStoreUtil.saveAutoPlay(value) 25 | } 26 | } 27 | 28 | fun saveAutoScanner(value: Boolean) { 29 | viewModelScope.launch(Dispatchers.IO) { 30 | dataStoreUtil.saveAutoScanner(value) 31 | } 32 | } 33 | 34 | fun saveForceDarkTheme(value: Boolean) { 35 | viewModelScope.launch(Dispatchers.IO) { 36 | dataStoreUtil.saveForceDarkTheme(value) 37 | } 38 | } 39 | 40 | 41 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/viewModel/SongViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.viewModel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.flow.stateIn 10 | import kotlinx.coroutines.launch 11 | import me.spica27.spicamusic.db.dao.PlaylistDao 12 | import me.spica27.spicamusic.db.dao.SongDao 13 | import me.spica27.spicamusic.db.entity.Playlist 14 | import me.spica27.spicamusic.db.entity.Song 15 | import timber.log.Timber 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class SongViewModel 20 | @Inject constructor( 21 | private val songDao: SongDao, 22 | private val playlistDao: PlaylistDao 23 | ) : ViewModel() { 24 | 25 | fun getSongFlow(id: Long) = songDao.getSongFlowWithId(id) 26 | 27 | // 所有歌曲 28 | val allSongs: StateFlow> = songDao.getAll() 29 | .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) 30 | 31 | // 所有收藏的歌曲 32 | val allLikeSongs: StateFlow> = songDao.getAllLikeSong() 33 | .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) 34 | 35 | // 所有歌单 36 | val allPlayList: StateFlow> = playlistDao.getAllPlaylist() 37 | .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) 38 | 39 | 40 | // 切换喜欢状态 41 | fun toggleFavorite(id: Long) { 42 | Timber.e("toggleFavorite: $id") 43 | viewModelScope.launch(Dispatchers.IO) { 44 | songDao.toggleLike(id) 45 | } 46 | } 47 | 48 | // 添加歌单 49 | fun addPlayList(value: String) { 50 | viewModelScope.launch { 51 | playlistDao.insertPlaylist(Playlist(playlistName = value)) 52 | } 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/visualiser/FFTListener.java: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.visualiser; 2 | 3 | public interface FFTListener { 4 | void onFFTReady(int sampleRateHz, int channelCount, float[] fft); 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/visualiser/VisualizerDrawableManager.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.visualiser 2 | 3 | import androidx.annotation.OptIn 4 | import androidx.media3.common.util.UnstableApi 5 | import me.spica27.spicamusic.visualiser.drawable.BlurVisualiser 6 | import me.spica27.spicamusic.visualiser.drawable.CircleVisualiser 7 | import me.spica27.spicamusic.visualiser.drawable.LineVisualiser 8 | import me.spica27.spicamusic.visualiser.drawable.VisualiserDrawable 9 | 10 | @OptIn(UnstableApi::class) 11 | class VisualizerDrawableManager { 12 | 13 | enum class VisualiserType { 14 | LINE, CIRCLE, BLUR, RAIN 15 | } 16 | 17 | 18 | companion object { 19 | private val visualiserDrawables = hashMapOf( 20 | VisualiserType.CIRCLE to CircleVisualiser(), 21 | VisualiserType.BLUR to BlurVisualiser(), 22 | VisualiserType.LINE to LineVisualiser(), 23 | VisualiserType.RAIN to BlurVisualiser(), 24 | ) 25 | } 26 | 27 | private var currentVisualiserType = VisualiserType.LINE 28 | 29 | 30 | fun nextVisualiserType() { 31 | currentVisualiserType = when (currentVisualiserType) { 32 | VisualiserType.LINE -> VisualiserType.CIRCLE 33 | VisualiserType.CIRCLE -> VisualiserType.BLUR 34 | VisualiserType.BLUR -> VisualiserType.RAIN 35 | VisualiserType.RAIN -> VisualiserType.LINE 36 | } 37 | } 38 | 39 | 40 | fun setVisualiserType(type: VisualiserType) { 41 | currentVisualiserType = type 42 | } 43 | 44 | fun setSecondaryColor(color: Int) { 45 | visualiserDrawables.values.forEach { 46 | it.secondaryColor = color 47 | } 48 | } 49 | 50 | fun setRadius(radius: Int) { 51 | visualiserDrawables.values.forEach { 52 | it.radius = radius 53 | } 54 | } 55 | 56 | fun setThemeColor(color: Int) { 57 | visualiserDrawables.values.forEach { 58 | it.themeColor = color 59 | } 60 | } 61 | 62 | 63 | fun setVisualiserBounds(width: Int, height: Int) { 64 | visualiserDrawables.values.forEach { 65 | it.setBounds(width, height) 66 | } 67 | } 68 | 69 | 70 | fun getVisualiserDrawable(): VisualiserDrawable { 71 | return visualiserDrawables[currentVisualiserType] 72 | ?: throw IllegalStateException("VisualiserDrawable not found") 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/visualiser/drawable/BlurVisualiser.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.visualiser.drawable 2 | 3 | import android.graphics.Canvas 4 | import android.graphics.CornerPathEffect 5 | import android.graphics.Paint 6 | import android.graphics.Path 7 | import androidx.annotation.OptIn 8 | import androidx.media3.common.util.UnstableApi 9 | import me.spica27.spicamusic.utils.dp 10 | import me.spica27.spicamusic.visualiser.MusicVisualiser 11 | import kotlin.math.cos 12 | import kotlin.math.sin 13 | import androidx.core.graphics.withSave 14 | import timber.log.Timber 15 | 16 | @OptIn(UnstableApi::class) 17 | class BlurVisualiser : VisualiserDrawable() { 18 | 19 | 20 | private val paint = Paint().apply { 21 | pathEffect = CornerPathEffect(10f) 22 | style = Paint.Style.STROKE 23 | strokeWidth = 3.dp 24 | strokeCap = Paint.Cap.ROUND 25 | } 26 | 27 | 28 | private val path1 = Path() 29 | 30 | private val path2 = Path() 31 | 32 | private val decelerateInterpolator by lazy { 33 | android.view.animation.DecelerateInterpolator() 34 | } 35 | 36 | /** 37 | * 计算圆弧上的某一点 38 | */ 39 | private fun calcPoint(centerX: Int, centerY: Int, radius: Int, angle: Float): IntArray { 40 | val x = (centerX + radius * cos((angle) * Math.PI / 180)).toInt() 41 | val y = (centerY + radius * sin((angle) * Math.PI / 180)).toInt() 42 | return intArrayOf(x, y) 43 | } 44 | 45 | // 采集到的数据 46 | private val yList by lazy { 47 | arrayListOf().apply { 48 | for (i in 0 until MusicVisualiser.FREQUENCY_BAND_LIMITS.size) { 49 | add(radius) 50 | add(radius) 51 | } 52 | } 53 | } 54 | 55 | // 前一次采集的数据 56 | private val lastYList by lazy { 57 | arrayListOf().apply { 58 | for (i in 0 until MusicVisualiser.FREQUENCY_BAND_LIMITS.size) { 59 | add(radius) 60 | add(radius) 61 | } 62 | } 63 | } 64 | 65 | 66 | // 上次采样的时间 67 | private var lastSampleTime = 0L 68 | 69 | // 采样间隔 70 | private val interval = 125 71 | 72 | 73 | override fun draw(canvas: Canvas) { 74 | Timber.tag("BlurVisualiser").d("draw()") 75 | Timber.tag("BlurVisualiser").d("width = ${width}") 76 | Timber.tag("BlurVisualiser").d("height = ${height}") 77 | canvas.translate(width / 2f, height / 2f) 78 | if (yList.size != lastYList.size) { 79 | return 80 | } 81 | canvas.withSave { 82 | path1.reset() 83 | path2.reset() 84 | paint.color = themeColor 85 | 86 | val fraction = 87 | decelerateInterpolator.getInterpolation(((System.currentTimeMillis() - lastSampleTime).toFloat() / interval)) 88 | .coerceIn( 89 | 0f, 1f 90 | ) 91 | 92 | 93 | for (index in 0 until yList.size) { 94 | val lastY = lastYList[index] 95 | val y = yList[index] 96 | 97 | val curY = lastY + (y - lastY) * fraction 98 | 99 | val curY2 = radius - (curY - radius) 100 | 101 | val angle = 360f / yList.size * index 102 | 103 | val p1 = calcPoint(0, 0, curY.toInt(), angle) 104 | 105 | val p2 = calcPoint(0, 0, curY2.toInt(), angle) 106 | 107 | drawLine(p1[0].toFloat(), p1[1].toFloat(), p2[0].toFloat(), p2[1].toFloat(), paint) 108 | 109 | if (index == 0) { 110 | path1.moveTo(p1[0].toFloat(), p1[1].toFloat()) 111 | path2.moveTo(p2[0].toFloat(), p2[1].toFloat()) 112 | } else { 113 | path1.lineTo(p1[0].toFloat(), p1[1].toFloat()) 114 | path2.lineTo(p2[0].toFloat(), p2[1].toFloat()) 115 | } 116 | } 117 | 118 | path1.close() 119 | path2.close() 120 | 121 | drawPath(path1, paint) 122 | 123 | drawPath(path2, paint) 124 | } 125 | } 126 | 127 | override fun update(list: List) { 128 | if (((System.currentTimeMillis() - lastSampleTime) < interval) && yList.isNotEmpty() && lastYList.isNotEmpty()) { 129 | return 130 | } 131 | 132 | // 记录上次的结果 133 | if (yList.isNotEmpty()) { 134 | lastYList.clear() 135 | lastYList.addAll(yList) 136 | } 137 | 138 | // 计算 139 | yList.clear() 140 | list.forEachIndexed { index, value -> 141 | val cur = radius + 30.dp * value 142 | val next = if (index == list.size - 1) { 143 | radius + 30.dp * list[0] 144 | } else { 145 | radius + 30.dp * list[index + 1] 146 | } 147 | yList.add(cur.toInt()) 148 | yList.add(next.toInt()) 149 | } 150 | lastSampleTime = System.currentTimeMillis() 151 | } 152 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/visualiser/drawable/LineVisualiser.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.visualiser.drawable 2 | 3 | import android.graphics.Canvas 4 | import android.graphics.Color 5 | import android.graphics.Paint 6 | import androidx.annotation.OptIn 7 | import androidx.core.graphics.ColorUtils 8 | import androidx.media3.common.util.UnstableApi 9 | import me.spica27.spicamusic.utils.dp 10 | import me.spica27.spicamusic.visualiser.MusicVisualiser 11 | import timber.log.Timber 12 | 13 | 14 | @OptIn(UnstableApi::class) 15 | class LineVisualiser : VisualiserDrawable() { 16 | 17 | 18 | // 上次采样的时间 19 | private var lastSampleTime = 0L 20 | 21 | // 采样间隔 22 | private val interval = 125 23 | 24 | 25 | // 采集到的数据 26 | private val yList by lazy { 27 | arrayListOf().apply { 28 | for (i in 0 until MusicVisualiser.FREQUENCY_BAND_LIMITS.size) { 29 | add(radius) 30 | add(radius) 31 | } 32 | } 33 | } 34 | 35 | // 前一次采集的数据 36 | private val lastYList by lazy { 37 | arrayListOf().apply { 38 | for (i in 0 until MusicVisualiser.FREQUENCY_BAND_LIMITS.size) { 39 | add(radius) 40 | add(radius) 41 | } 42 | } 43 | } 44 | 45 | // 记录最高的Y值 用于回落动画 46 | private val maxYList by lazy { 47 | arrayListOf().apply { 48 | for (i in 0 until MusicVisualiser.FREQUENCY_BAND_LIMITS.size) { 49 | add(radius) 50 | add(radius) 51 | } 52 | } 53 | } 54 | 55 | 56 | private val pointPaint = Paint().apply { 57 | // set paint 58 | color = Color.BLACK 59 | // set style 60 | style = Paint.Style.FILL 61 | strokeWidth = 8.dp 62 | strokeCap = Paint.Cap.ROUND 63 | } 64 | 65 | 66 | private val decelerateInterpolator by lazy { 67 | android.view.animation.DecelerateInterpolator() 68 | } 69 | 70 | override fun draw(canvas: Canvas) { 71 | canvas.translate(width / 2f, height / 2f) 72 | val fraction = 73 | decelerateInterpolator.getInterpolation(((System.currentTimeMillis() - lastSampleTime).toFloat() / interval)) 74 | .coerceIn( 75 | 0f, 1f 76 | ) 77 | 78 | if (lastYList.size == yList.size) { 79 | canvas.save() 80 | for (i in 0 until lastYList.size) { 81 | canvas.rotate(360f / lastYList.size) 82 | val lastY = lastYList[i] 83 | val y = yList[i] 84 | 85 | val curY = lastY + (y - lastY) * fraction 86 | 87 | if (maxYList.size == yList.size) { 88 | val maxY = maxYList[i] 89 | if (maxY < curY) { 90 | maxYList[i] = curY.toInt() 91 | } else { 92 | maxYList[i] = (maxYList[i] - (1.dp) / 60f).toInt() 93 | } 94 | pointPaint.color = ColorUtils.setAlphaComponent(themeColor, 100) 95 | canvas.drawLine( 96 | 0f, maxYList[i] * 1f, 0f, radius * 1f, pointPaint 97 | ) 98 | pointPaint.color = themeColor 99 | } 100 | 101 | 102 | canvas.drawLine( 103 | 0f, curY * 1f, 0f, radius * 1f, pointPaint 104 | ) 105 | } 106 | canvas.restore() 107 | } 108 | } 109 | 110 | override fun update(list: List) { 111 | if (((System.currentTimeMillis() - lastSampleTime) < interval) && 112 | yList.isNotEmpty() && lastYList.isNotEmpty() 113 | ) { 114 | return 115 | } 116 | 117 | // 记录上次的结果 118 | if (yList.isNotEmpty()) { 119 | lastYList.clear() 120 | lastYList.addAll(yList) 121 | } 122 | 123 | // 计算 124 | yList.clear() 125 | list.forEachIndexed { index, value -> 126 | val cur = radius + 30.dp * value 127 | val next = if (index == list.size - 1) { 128 | radius + 30.dp * list[0] 129 | } else { 130 | radius + 30.dp * list[index + 1] 131 | } 132 | yList.add(cur.toInt()) 133 | yList.add(next.toInt()) 134 | } 135 | if (maxYList.size != yList.size) { 136 | maxYList.clear() 137 | maxYList.addAll(yList) 138 | } 139 | 140 | lastSampleTime = System.currentTimeMillis() 141 | } 142 | 143 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/visualiser/drawable/VisualiserDrawable.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.visualiser.drawable 2 | 3 | import android.graphics.Canvas 4 | 5 | 6 | abstract class VisualiserDrawable { 7 | 8 | // 半径 9 | var radius = 0 10 | 11 | // 主题色 12 | var themeColor = 0 13 | 14 | // 二级颜色 15 | var secondaryColor = 0 16 | 17 | // 背景颜色 18 | var backgroundColor = 0 19 | var width = 0 20 | 21 | var height = 0 22 | 23 | open fun setBounds(width: Int, height: Int) { 24 | this.width = width 25 | this.height = height 26 | } 27 | 28 | 29 | abstract fun draw(canvas: Canvas) 30 | 31 | abstract fun update(list: List) 32 | 33 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/PlaylistItem.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.MoreVert 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.IconButton 16 | import androidx.compose.material3.MaterialTheme 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.unit.dp 22 | import me.spica27.spicamusic.db.entity.Playlist 23 | 24 | 25 | /// 歌单条目 26 | @Composable 27 | fun PlaylistItem( 28 | modifier: Modifier = Modifier, 29 | playlist: Playlist, 30 | onClick: () -> Unit = {}, 31 | onClickMenu: () -> Unit = {}, 32 | showMenu: Boolean = false 33 | ) { 34 | Row(modifier = modifier 35 | .clickable { 36 | onClick() 37 | } 38 | .padding(horizontal = 16.dp, vertical = 6.dp) 39 | .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { 40 | Box( 41 | modifier = Modifier 42 | .width(50.dp) 43 | .height(50.dp) 44 | .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.medium), 45 | contentAlignment = Alignment.Center 46 | ) { 47 | Text( 48 | text = (playlist.playlistName.firstOrNull() ?: "A").toString(), 49 | style = MaterialTheme.typography.bodyLarge, 50 | color = MaterialTheme.colorScheme.onSurface 51 | ) 52 | } 53 | Spacer(modifier = Modifier.width(16.dp)) 54 | Text( 55 | modifier = Modifier.weight(1f), 56 | text = playlist.playlistName, style = MaterialTheme.typography.bodyMedium.copy( 57 | color = MaterialTheme.colorScheme.onSurface, 58 | ) 59 | ) 60 | if (showMenu) { 61 | IconButton( 62 | onClick = onClickMenu, 63 | ) { 64 | Icon( 65 | imageVector = Icons.Default.MoreVert, 66 | contentDescription = "More", 67 | tint = MaterialTheme.colorScheme.onSurface 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/VisualizerView.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget 2 | 3 | import android.animation.ValueAnimator 4 | import android.content.Context 5 | import android.graphics.Canvas 6 | import android.os.Handler 7 | import android.os.HandlerThread 8 | import android.util.AttributeSet 9 | import android.view.View 10 | import androidx.media3.common.util.UnstableApi 11 | import me.spica27.spicamusic.utils.dp 12 | import me.spica27.spicamusic.visualiser.MusicVisualiser 13 | import me.spica27.spicamusic.visualiser.VisualizerDrawableManager 14 | import timber.log.Timber 15 | import java.util.concurrent.locks.ReentrantLock 16 | 17 | 18 | @UnstableApi 19 | class VisualizerView : View, MusicVisualiser.Listener { 20 | 21 | 22 | private lateinit var mThread: HandlerThread 23 | 24 | private lateinit var mHandler: Handler 25 | 26 | constructor(context: Context?) : super(context) 27 | constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) 28 | constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( 29 | context, attrs, defStyleAttr 30 | ) 31 | 32 | init { 33 | setOnClickListener { 34 | nextVisualiserType() 35 | } 36 | } 37 | 38 | private val infiniteAnim = ValueAnimator.ofFloat(0f,1f).apply { 39 | repeatCount = ValueAnimator.INFINITE 40 | duration = 1000 41 | addUpdateListener { 42 | postInvalidateOnAnimation() 43 | } 44 | } 45 | 46 | 47 | override fun onAttachedToWindow() { 48 | super.onAttachedToWindow() 49 | mThread = HandlerThread("VisualizerSurfaceView") 50 | mThread.start() 51 | mHandler = Handler(mThread.looper) 52 | musicVisualiser.setListener(this) 53 | infiniteAnim.start() 54 | musicVisualiser.ready() 55 | Timber.tag("VisualizerSurfaceView").d("onAttachedToWindow()") 56 | } 57 | 58 | override fun onDetachedFromWindow() { 59 | super.onDetachedFromWindow() 60 | musicVisualiser.dispose() 61 | mHandler.removeCallbacksAndMessages(null) 62 | mThread.quitSafely() 63 | musicVisualiser.setListener(null) 64 | infiniteAnim.cancel() 65 | Timber.tag("VisualizerSurfaceView").d("onDetachedFromWindow()") 66 | } 67 | 68 | 69 | fun setThemeColor(color: Int) { 70 | visualizerDrawableManager.setThemeColor(color) 71 | } 72 | 73 | 74 | private val visualizerDrawableManager = VisualizerDrawableManager() 75 | 76 | 77 | private val lock = ReentrantLock() 78 | 79 | override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { 80 | super.onSizeChanged(w, h, oldw, oldh) 81 | lock.lock() 82 | visualizerDrawableManager.setVisualiserBounds(w, h) 83 | visualizerDrawableManager.setRadius((width / 2 - 60.dp).toInt()) 84 | lock.unlock() 85 | } 86 | 87 | 88 | fun nextVisualiserType() { 89 | visualizerDrawableManager.nextVisualiserType() 90 | } 91 | 92 | 93 | override fun onDraw(canvas: Canvas) { 94 | super.onDraw(canvas) 95 | lock.lock() 96 | visualizerDrawableManager.getVisualiserDrawable().draw(canvas) 97 | lock.unlock() 98 | } 99 | 100 | 101 | // 波形图绘制效果集合 102 | private val musicVisualiser: MusicVisualiser = MusicVisualiser() 103 | 104 | private val fft = arrayListOf() 105 | 106 | override fun getDrawData(list: List) { 107 | lock.lock() 108 | fft.clear() 109 | fft.addAll(list) 110 | lock.unlock() 111 | mHandler.post { 112 | lock.lock() 113 | visualizerDrawableManager.getVisualiserDrawable().update(fft) 114 | lock.unlock() 115 | mHandler.removeCallbacksAndMessages(null) 116 | } 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/VisualizerWidget.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget 2 | 3 | import androidx.compose.foundation.Canvas 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.aspectRatio 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.mutableStateOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.runtime.setValue 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.geometry.Size 14 | 15 | 16 | @Composable 17 | fun VisualizerView(modifier: Modifier = Modifier) { 18 | var canvasSize by remember { mutableStateOf(Size(0f, 0f)) } 19 | Box( 20 | modifier = modifier 21 | .aspectRatio(1f) 22 | ) { 23 | Canvas( 24 | modifier = Modifier.fillMaxSize() 25 | ) { 26 | canvasSize = size 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/audio_seekbar/AmplitudeType.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget.audio_seekbar 2 | 3 | // 振幅类型 4 | enum class AmplitudeType { 5 | Avg, Min, Max 6 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/audio_seekbar/BrushExt.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget.audio_seekbar 2 | 3 | import androidx.compose.animation.core.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.State 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.geometry.Offset 8 | import androidx.compose.ui.graphics.Brush 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.TileMode 11 | 12 | 13 | @Composable 14 | fun Brush.Companion.infiniteHorizontalGradient( 15 | infiniteTransition: InfiniteTransition = rememberInfiniteTransition(label = "infiniteTransition"), 16 | animation: DurationBasedAnimationSpec = tween(2000, easing = LinearEasing), 17 | colors: List, 18 | width: Float 19 | ): Brush { 20 | val offset by getInfiniteOffset(infiniteTransition, animation, width) 21 | return horizontalGradient(colors, offset, offset + width, TileMode.Mirror) 22 | } 23 | 24 | @Composable 25 | fun Brush.Companion.infiniteVerticalGradient( 26 | infiniteTransition: InfiniteTransition = rememberInfiniteTransition(label = "infiniteTransition"), 27 | animation: DurationBasedAnimationSpec = tween(2000, easing = LinearEasing), 28 | colors: List, 29 | width: Float 30 | ): Brush { 31 | val offset by getInfiniteOffset(infiniteTransition, animation, width) 32 | return verticalGradient(colors, offset, offset + width, TileMode.Mirror) 33 | } 34 | 35 | @Composable 36 | fun Brush.Companion.infiniteLinearGradient( 37 | infiniteTransition: InfiniteTransition = rememberInfiniteTransition(label = "infiniteTransition"), 38 | animation: DurationBasedAnimationSpec = tween(2000, easing = LinearEasing), 39 | colors: List, 40 | width: Float 41 | ): Brush { 42 | val offset by getInfiniteOffset(infiniteTransition, animation, width) 43 | return linearGradient( 44 | colors, 45 | Offset(offset, offset), 46 | Offset(offset + width, offset + width), 47 | TileMode.Mirror 48 | ) 49 | } 50 | 51 | @Composable 52 | private fun getInfiniteOffset( 53 | infiniteTransition: InfiniteTransition, 54 | animation: DurationBasedAnimationSpec, 55 | width: Float 56 | ): State { 57 | return infiniteTransition.animateFloat( 58 | initialValue = 0f, 59 | targetValue = width * 2, 60 | animationSpec = infiniteRepeatable( 61 | animation = animation, 62 | repeatMode = RepeatMode.Restart 63 | ), label = "infiniteTransition" 64 | ) 65 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/audio_seekbar/Ext.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget.audio_seekbar 2 | 3 | import kotlin.math.ceil 4 | import kotlin.math.roundToInt 5 | 6 | 7 | internal fun Iterable.fillToSize(size: Int, transform: (List) -> T): List { 8 | val capacity = ceil(size.safeDiv(count())).roundToInt() 9 | return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform) 10 | } 11 | 12 | internal fun Iterable.chunkToSize(size: Int, transform: (List) -> T): List { 13 | val chunkSize = count() / size 14 | val remainder = count() % size 15 | val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt() 16 | val chunkIteration = filterIndexed { index, _ -> 17 | remainderIndex == 0 || index % remainderIndex != 0 18 | }.chunked(chunkSize, transform) 19 | return when (size) { 20 | chunkIteration.count() -> chunkIteration 21 | else -> chunkIteration.chunkToSize(size, transform) 22 | } 23 | } 24 | 25 | internal fun Iterable.normalize(min: Float, max: Float): List { 26 | return map { (max - min) * ((it - min()) safeDiv (max() - min())) + min } 27 | } 28 | 29 | private fun Int.safeDiv(value: Int): Float { 30 | return if (value == 0) return 0F else this / value.toFloat() 31 | } 32 | 33 | private infix fun Float.safeDiv(value: Float): Float { 34 | return if (value == 0f) return 0F else this / value 35 | } -------------------------------------------------------------------------------- /app/src/main/java/me/spica27/spicamusic/widget/audio_seekbar/WaveformAlignment.kt: -------------------------------------------------------------------------------- 1 | package me.spica27.spicamusic.widget.audio_seekbar 2 | 3 | 4 | // 位置对齐 5 | enum class WaveformAlignment { 6 | Top, Center, Bottom 7 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/default_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/drawable/default_cover.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_app.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_audio_line.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_dvd.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_new.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_next.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pause.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_playlist_remove.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_plus.xml: -------------------------------------------------------------------------------- 1 | 7 | 14 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_pre.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_remove.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap/default_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yangSpica27/SPICaMusic_Android/bb787990561f5ee803fa7bb4b41af7b76ef5d7e1/app/src/main/res/mipmap/default_cover.jpg -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #F44336 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | SPICaMusic 3 | Settings 4 | 5 | First Fragment 6 | Second Fragment 7 | Next 8 | Previous 9 | 10 | 由此向下轻扫回到详情 11 | 正在播放 12 | 播放列表 13 | 选择全部 14 | 全不选 15 | 原始 16 | 作者自用 17 | 低音增强 18 | 低音减弱 19 | 人声增强 20 | 人声减弱 21 | 回放增益 22 | 曲目增益 23 | 专辑增益 24 | 25 | 频率响应 26 | 模式 27 | 前级 28 | 均衡器 29 | 预设 30 | 频响曲线 31 | 关键字... 32 | 新增 33 | 删除 34 | 添加到播放列表 35 | 信息 36 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |