├── app ├── .gitignore ├── src │ ├── main │ │ ├── java │ │ │ └── me │ │ │ │ └── rerere │ │ │ │ └── rainmusic │ │ │ │ ├── util │ │ │ │ ├── C.kt │ │ │ │ ├── PreferenceUtil.kt │ │ │ │ ├── ConditionCheck.kt │ │ │ │ ├── okhttp │ │ │ │ │ ├── EnsureHttps.kt │ │ │ │ │ ├── UserAgentInterceptor.kt │ │ │ │ │ ├── HttpDsl.kt │ │ │ │ │ ├── RetryHelper.kt │ │ │ │ │ └── CookieHelper.kt │ │ │ │ ├── media │ │ │ │ │ └── MediaItemBuilder.kt │ │ │ │ ├── encrypt │ │ │ │ │ ├── RSA.kt │ │ │ │ │ └── Encryptor.kt │ │ │ │ ├── TimeUtil.kt │ │ │ │ ├── DataState.kt │ │ │ │ ├── NavigationUtil.kt │ │ │ │ └── ContextUtil.kt │ │ │ │ ├── ui │ │ │ │ ├── theme │ │ │ │ │ ├── Type.kt │ │ │ │ │ ├── Color.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── local │ │ │ │ │ ├── LocalUserData.kt │ │ │ │ │ └── LocalNavController.kt │ │ │ │ ├── states │ │ │ │ │ ├── GridPaging.kt │ │ │ │ │ ├── Media3Session.kt │ │ │ │ │ └── PlayerStates.kt │ │ │ │ ├── component │ │ │ │ │ ├── PlaceholderFade.kt │ │ │ │ │ ├── RequireLogin.kt │ │ │ │ │ ├── Banner.kt │ │ │ │ │ └── AppBar.kt │ │ │ │ └── screen │ │ │ │ │ ├── dailysong │ │ │ │ │ ├── DailySongViewModel.kt │ │ │ │ │ └── DailySongScreen.kt │ │ │ │ │ ├── Screen.kt │ │ │ │ │ ├── login │ │ │ │ │ ├── LoginViewModel.kt │ │ │ │ │ └── LoginScreen.kt │ │ │ │ │ ├── playlist │ │ │ │ │ └── PlaylistViewModel.kt │ │ │ │ │ ├── search │ │ │ │ │ └── SearchScreen.kt │ │ │ │ │ ├── player │ │ │ │ │ └── PlayerScreenViewModel.kt │ │ │ │ │ ├── test │ │ │ │ │ └── TestScreen.kt │ │ │ │ │ └── index │ │ │ │ │ ├── IndexViewModel.kt │ │ │ │ │ ├── page │ │ │ │ │ └── LibraryPage.kt │ │ │ │ │ └── IndexScreen.kt │ │ │ │ ├── data │ │ │ │ ├── retrofit │ │ │ │ │ ├── api │ │ │ │ │ │ ├── model │ │ │ │ │ │ │ ├── LikeResult.kt │ │ │ │ │ │ │ ├── HighQualityPlaylist.kt │ │ │ │ │ │ │ ├── Lyric.kt │ │ │ │ │ │ │ ├── Toplists.kt │ │ │ │ │ │ │ ├── AccountDetail.kt │ │ │ │ │ │ │ ├── UserPlaylists.kt │ │ │ │ │ │ │ ├── MusicDetails.kt │ │ │ │ │ │ │ └── DailyRecommendSongs.kt │ │ │ │ │ │ └── NeteaseMusicApi.kt │ │ │ │ │ ├── weapi │ │ │ │ │ │ ├── model │ │ │ │ │ │ │ ├── LikeList.kt │ │ │ │ │ │ │ ├── SubPlaylistResult.kt │ │ │ │ │ │ │ ├── SignResult.kt │ │ │ │ │ │ │ ├── TopPlaylists.kt │ │ │ │ │ │ │ ├── PersonalizedPlaylist.kt │ │ │ │ │ │ │ ├── PlaylistCategory.kt │ │ │ │ │ │ │ ├── HotPlaylistTag.kt │ │ │ │ │ │ │ └── LoginResponse.kt │ │ │ │ │ │ └── NeteaseMusicWeApi.kt │ │ │ │ │ └── eapi │ │ │ │ │ │ ├── NeteaseMusicEApi.kt │ │ │ │ │ │ └── model │ │ │ │ │ │ └── MusicUrl.kt │ │ │ │ ├── model │ │ │ │ │ ├── UserData.kt │ │ │ │ │ ├── MusicInfo.kt │ │ │ │ │ └── Playlist.kt │ │ │ │ └── paging │ │ │ │ │ └── TopPlaylistPagingSource.kt │ │ │ │ ├── AppContext.kt │ │ │ │ ├── repo │ │ │ │ ├── YiYanRepo.kt │ │ │ │ ├── UserRepo.kt │ │ │ │ └── MusicRepo.kt │ │ │ │ ├── di │ │ │ │ └── NetworkModule.kt │ │ │ │ ├── service │ │ │ │ └── MusicService.kt │ │ │ │ └── RouteActivity.kt │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_rounded.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ ├── ic_launcher_rounded_background.png │ │ │ │ └── ic_launcher_rounded_foreground.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_rounded.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ ├── ic_launcher_rounded_background.png │ │ │ │ └── ic_launcher_rounded_foreground.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_rounded.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ ├── ic_launcher_rounded_background.png │ │ │ │ └── ic_launcher_rounded_foreground.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_rounded.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ ├── ic_launcher_rounded_background.png │ │ │ │ └── ic_launcher_rounded_foreground.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_rounded.png │ │ │ │ ├── ic_launcher_background.png │ │ │ │ ├── ic_launcher_foreground.png │ │ │ │ ├── ic_launcher_rounded_background.png │ │ │ │ └── ic_launcher_rounded_foreground.png │ │ │ ├── drawable-nodpi │ │ │ │ └── netease_music.png │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_rounded.xml │ │ │ └── values-night │ │ │ │ └── themes.xml │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── me │ │ │ └── rerere │ │ │ └── rainmusic │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── me │ │ └── rerere │ │ └── rainmusic │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── art ├── index.png ├── lyric.png ├── player.png └── discover.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── LICENSE ├── gradle.properties ├── gradlew.bat ├── README.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /art/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/art/index.png -------------------------------------------------------------------------------- /art/lyric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/art/lyric.png -------------------------------------------------------------------------------- /art/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/art/player.png -------------------------------------------------------------------------------- /art/discover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/art/discover.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/C.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | // 全局常量 4 | const val RainMusicProtocol = "rainmusic" -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-nodpi/netease_music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/drawable-nodpi/netease_music.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_rounded_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_rounded_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_rounded_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_rounded_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_rounded_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_rounded_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_rounded_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_rounded_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_rounded_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_rounded_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_rounded_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_rounded_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_rounded_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded_background.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-ovo/RainMusic/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_rounded_foreground.png -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | // Set of Material typography styles to start with 6 | val Typography = Typography() -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/LikeResult.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class LikeResult( 6 | @SerializedName("code") 7 | val code: Int 8 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/LikeList.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class LikeList( 7 | @SerializedName("ids") 8 | val ids: List 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/SubPlaylistResult.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class SubPlaylistResult( 6 | @SerializedName("code") 7 | val code: Int 8 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Apr 23 15:38:48 CST 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/local/LocalUserData.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.local 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import me.rerere.rainmusic.data.model.UserData 5 | 6 | val LocalUserData = compositionLocalOf { 7 | UserData.VISITOR 8 | } -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | RainMusic 3 | EXAMPLE 4 | Add widget 5 | This is an app widget description 6 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/local/LocalNavController.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.local 2 | 3 | import androidx.compose.runtime.compositionLocalOf 4 | import androidx.navigation.NavController 5 | 6 | val LocalNavController = compositionLocalOf { 7 | error("Did not init yet!") 8 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | 2 | # keep attr 3 | -keepattributes SourceFile,LineNumberTable 4 | 5 | # Disable obfuscate/optimize 6 | -dontobfuscate 7 | -dontoptimize 8 | -verbose 9 | 10 | # Keep Self 11 | -keep class me.rerere.rainmusic.** 12 | 13 | # Keep Kotlin 14 | -keep class kotlin.** 15 | -keep class kotlinx.** -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_rounded.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/SignResult.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class SignResult( 6 | @SerializedName("code") val code: Int, 7 | @SerializedName("point") val point: Int?, 8 | @SerializedName("msg") val msg: String? 9 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/PreferenceUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import me.rerere.rainmusic.AppContext 6 | 7 | fun sharedPreferencesOf( 8 | name: String 9 | ): SharedPreferences = AppContext.instance.getSharedPreferences(name, Context.MODE_PRIVATE) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/model/UserData.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.model 2 | 3 | data class UserData( 4 | val id: Long = -1L, 5 | val nickname: String = "", 6 | val avatarUrl: String = "" 7 | ){ 8 | val isVisitor = id == -1L 9 | 10 | companion object { 11 | // 游客模式 12 | val VISITOR = UserData() 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/ConditionCheck.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | /** 4 | * 确保T是确定数值中的一员 5 | * 某些函数的参数可能是确定的几个值,使用此函数确保参数在确定范围内 6 | * 7 | * @receiver 要校验的值 8 | * @param condition 该值的几种可能性 9 | */ 10 | fun T.requireOneOf(vararg condition: T) { 11 | require(condition.contains(this)) { 12 | "param must be one of: ${condition.joinToString(",")}" 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/test/java/me/rerere/rainmusic/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/AppContext.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.SharedPreferences 6 | import dagger.hilt.android.HiltAndroidApp 7 | 8 | @HiltAndroidApp 9 | class AppContext : Application() { 10 | override fun onCreate() { 11 | super.onCreate() 12 | instance = this 13 | } 14 | 15 | companion object { 16 | @JvmStatic 17 | lateinit var instance: AppContext 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/states/GridPaging.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.states 2 | 3 | import androidx.compose.foundation.lazy.grid.LazyGridScope 4 | import androidx.compose.runtime.Composable 5 | import androidx.paging.compose.LazyPagingItems 6 | 7 | fun LazyGridScope.items( 8 | items: LazyPagingItems, 9 | itemContent: @Composable (value: T?) -> Unit 10 | ) { 11 | items( 12 | count = items.itemCount 13 | ) { index -> 14 | val item = items[index] 15 | itemContent(item) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/TopPlaylists.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import me.rerere.rainmusic.data.model.Playlists 5 | 6 | data class TopPlaylists( 7 | @SerializedName("cat") 8 | val cat: String, 9 | @SerializedName("code") 10 | val code: Int, 11 | @SerializedName("more") 12 | val more: Boolean, 13 | @SerializedName("playlists") 14 | val playlists: List?, 15 | @SerializedName("total") 16 | val total: Int 17 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/okhttp/EnsureHttps.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.okhttp 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.Response 5 | 6 | class EnsureHttpsInterceptor : Interceptor { 7 | override fun intercept(chain: Interceptor.Chain): Response { 8 | val request = chain.request() 9 | return chain.proceed(if(request.isHttps) request else request.newBuilder().url(request.url.toString().https).build()) 10 | } 11 | } 12 | 13 | val String.https: String 14 | get() = if(startsWith("https")) this else replace("http://","https://") -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/HighQualityPlaylist.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import me.rerere.rainmusic.data.model.Playlists 5 | 6 | data class HighQualityPlaylist( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("lasttime") 10 | val lasttime: Long, 11 | @SerializedName("more") 12 | val more: Boolean, 13 | @SerializedName("playlists") 14 | val playlists: List, 15 | @SerializedName("total") 16 | val total: Int 17 | ) -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #FFE1F5FE 11 | #FF81D4FA 12 | #FF039BE5 13 | #FF01579B 14 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/eapi/NeteaseMusicEApi.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.eapi 2 | 3 | import com.google.gson.JsonObject 4 | import me.rerere.rainmusic.data.retrofit.eapi.model.MusicUrl 5 | import retrofit2.http.FieldMap 6 | import retrofit2.http.FormUrlEncoded 7 | import retrofit2.http.POST 8 | 9 | 10 | interface NeteaseMusicEApi { 11 | @POST("/eapi/song/enhance/player/url") 12 | @FormUrlEncoded 13 | suspend fun getMusicUrl(@FieldMap body: Map): MusicUrl 14 | 15 | @POST("/eapi/pl/count") 16 | @FormUrlEncoded 17 | suspend fun count(@FieldMap body: Map): JsonObject 18 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.theme 2 | 3 | import androidx.compose.material.Colors 4 | import androidx.compose.material3.ColorScheme 5 | 6 | fun ColorScheme.toMd2Colors(light: Boolean): Colors = Colors( 7 | primary = this.primary, 8 | onPrimary = this.onPrimary, 9 | primaryVariant = this.primary, 10 | secondary = this.secondary, 11 | onSecondary = this.onSecondary, 12 | secondaryVariant = this.secondary, 13 | background = this.background, 14 | onBackground = this.onBackground, 15 | surface = this.surface, 16 | onSurface = this.onSurface, 17 | error = this.error, 18 | onError = this.onError, 19 | isLight = light 20 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/okhttp/UserAgentInterceptor.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.okhttp 2 | 3 | import android.webkit.WebSettings 4 | import me.rerere.rainmusic.AppContext 5 | import okhttp3.Interceptor 6 | import okhttp3.Request 7 | import okhttp3.Response 8 | 9 | class UserAgentInterceptor( 10 | private val userAgent: String = WebSettings.getDefaultUserAgent( 11 | AppContext.instance 12 | ) 13 | ) : Interceptor { 14 | override fun intercept(chain: Interceptor.Chain): Response { 15 | val userAgentRequest: Request = chain.request() 16 | .newBuilder() 17 | .header("User-Agent", userAgent) 18 | .build() 19 | return chain.proceed(userAgentRequest) 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/okhttp/HttpDsl.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.okhttp 2 | 3 | import okhttp3.FormBody 4 | import okhttp3.OkHttpClient 5 | import okhttp3.Request 6 | import okhttp3.Response 7 | 8 | /** 9 | * 使用DSL创建一个http request 10 | */ 11 | fun OkHttpClient.request(builder: Request.Builder.() -> Unit): Response { 12 | val request = Request.Builder() 13 | .apply(builder) 14 | .build() 15 | return newCall(request).execute() 16 | } 17 | 18 | /** 19 | * 快速创建post form 20 | */ 21 | fun Request.Builder.post(vararg kv: Pair) { 22 | post( 23 | FormBody.Builder().apply { 24 | kv.forEach { 25 | add(it.first, it.second) 26 | } 27 | }.build() 28 | ) 29 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | google() 5 | mavenCentral() 6 | } 7 | plugins { 8 | id 'com.android.application' version '7.3.0-alpha09' 9 | id 'com.android.library' version '7.3.0-alpha09' 10 | id 'org.jetbrains.kotlin.android' version '1.5.31' 11 | } 12 | } 13 | dependencyResolutionManagement { 14 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 15 | repositories { 16 | google() 17 | mavenCentral() 18 | maven { url 'https://jitpack.io' } 19 | maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } 20 | // jcenter() 21 | } 22 | } 23 | rootProject.name = "RainMusic" 24 | include ':app' 25 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/media/MediaItemBuilder.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.media 2 | 3 | import androidx.media3.common.MediaItem 4 | import androidx.media3.common.MediaMetadata 5 | 6 | /** 7 | * 使用DSL构建MediaItem 8 | * 9 | * @param mediaId MediaId 10 | * @param buildScope MediaItem构建lambda 11 | */ 12 | fun buildMediaItem(mediaId: String, buildScope: MediaItem.Builder.() -> Unit): MediaItem = 13 | MediaItem.Builder() 14 | .setMediaId(mediaId) 15 | .apply(buildScope) 16 | .build() 17 | 18 | /** 19 | * 使用DSL构建 MediaMetadata 20 | * 21 | * @param scope MediaMetadata构建器 22 | */ 23 | fun MediaItem.Builder.metadata(scope: MediaMetadata.Builder.() -> Unit) { 24 | setMediaMetadata(MediaMetadata.Builder().apply(scope).build()) 25 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/me/rerere/rainmusic/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic 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.rerere.rainmusic", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/encrypt/RSA.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.encrypt 2 | 3 | import com.soywiz.krypto.encoding.hex 4 | import java.math.BigInteger 5 | 6 | private const val pubKey = "010001" 7 | private const val modulus = 8 | "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7" 9 | 10 | fun rsaEncrypt(text: String): String { 11 | val text = StringBuffer(text).reverse().toString() 12 | val biText = BigInteger(text.toByteArray().hex, 16) 13 | val biEx = BigInteger(pubKey, 16) 14 | val biMod = BigInteger(modulus, 16) 15 | val biRet: BigInteger = biText.modPow(biEx, biMod) 16 | return biRet.toString(16).padStart(256, '0') 17 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/model/MusicInfo.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.model 2 | 3 | import android.net.Uri 4 | import androidx.media3.common.MediaItem 5 | import me.rerere.rainmusic.util.media.buildMediaItem 6 | import me.rerere.rainmusic.util.media.metadata 7 | 8 | data class MusicInfo( 9 | val id: Long, 10 | val name: String, 11 | val artist: String, 12 | val musicUrl: String, 13 | val artworkUrl: String 14 | ) { 15 | fun toMediaItem(): MediaItem = buildMediaItem(id.toString()) { 16 | metadata { 17 | setTitle(name) 18 | setArtist(artist) 19 | setMediaUri(Uri.parse(musicUrl)) 20 | setArtworkUri(Uri.parse(artworkUrl)) 21 | } 22 | } 23 | 24 | companion object { 25 | val PersonalFM = MusicInfo( 26 | id = 0L, 27 | name = "私人FM", 28 | artist = "私人FM", 29 | musicUrl = "", 30 | artworkUrl = "" 31 | ) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/component/PlaceholderFade.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.component 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.composed 5 | import coil.annotation.ExperimentalCoilApi 6 | import coil.compose.ImagePainter 7 | import com.google.accompanist.placeholder.PlaceholderHighlight 8 | import com.google.accompanist.placeholder.material.placeholder 9 | import com.google.accompanist.placeholder.material.shimmer 10 | 11 | fun Modifier.shimmerPlaceholder( 12 | visible: Boolean 13 | ) = composed { 14 | Modifier.placeholder( 15 | visible = visible, 16 | highlight = PlaceholderHighlight.shimmer() 17 | ) 18 | } 19 | 20 | @OptIn(ExperimentalCoilApi::class) 21 | fun Modifier.shimmerPlaceholder( 22 | imagePainter: ImagePainter 23 | ) = composed { 24 | Modifier.placeholder( 25 | visible = imagePainter.state is ImagePainter.State.Loading, 26 | highlight = PlaceholderHighlight.shimmer() 27 | ) 28 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/TimeUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | import android.annotation.SuppressLint 4 | import java.text.SimpleDateFormat 5 | import java.util.* 6 | 7 | @SuppressLint("SimpleDateFormat") 8 | private val sdf = SimpleDateFormat("yyyy年MM月dd日") 9 | 10 | @SuppressLint("SimpleDateFormat") 11 | private val sdfDetail = SimpleDateFormat("yyyy年MM月dd日 HH:mm") 12 | 13 | /** 14 | * 获取当前时间戳 15 | * 16 | * @return 当前时间戳 17 | */ 18 | fun now() = System.currentTimeMillis() 19 | 20 | /** 21 | * 将时间戳格式化成可读时间 22 | * 23 | * @receiver 时间戳 24 | * @param detail 是否显示"时分秒" 25 | * @return 可读时间 26 | */ 27 | fun Long.format( 28 | detail: Boolean = false 29 | ): String = if (detail) sdfDetail.format(Date(this)) else sdf.format(Date(this)) 30 | 31 | /** 32 | * 将"时间长度"格式化为可读时间 33 | */ 34 | fun Long.formatAsPlayerTime() : String { 35 | val minutes = String.format("%02d", this / 60_000L) 36 | val seconds = String.format("%02d", (this % 60_000L) / 1000L) 37 | return "$minutes:$seconds" 38 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/dailysong/DailySongViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.dailysong 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | import me.rerere.rainmusic.data.retrofit.api.model.DailyRecommendSongs 10 | import me.rerere.rainmusic.repo.MusicRepo 11 | import me.rerere.rainmusic.util.DataState 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class DailySongViewModel @Inject constructor( 16 | private val musicRepo: MusicRepo 17 | ) : ViewModel() { 18 | val dailySongs: MutableStateFlow> = MutableStateFlow(DataState.Empty) 19 | 20 | init { 21 | musicRepo.getDailyRecommendSongs() 22 | .onEach { 23 | dailySongs.value = it 24 | }.launchIn(viewModelScope) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/okhttp/RetryHelper.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.okhttp 2 | 3 | import android.util.Log 4 | import okhttp3.Interceptor 5 | import okhttp3.Response 6 | import java.io.IOException 7 | 8 | class RetryHelper( 9 | private val maxRetryTimes: Int = 3 10 | ) : Interceptor { 11 | override fun intercept(chain: Interceptor.Chain): Response { 12 | val request = chain.request() 13 | var response = try { 14 | chain.proceed(request) 15 | } catch (e: Exception) { 16 | null 17 | } 18 | var retryNum = 0 19 | while (response?.isSuccessful != true && retryNum < maxRetryTimes) { 20 | retryNum++ 21 | Log.i("RetryInterceptor", "retry ${request.url} for the $retryNum time") 22 | response = try { 23 | chain.proceed(request) 24 | }catch (e: Exception){ 25 | null 26 | } 27 | } 28 | return response ?: throw IOException("no response at all") 29 | } 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 RERERE 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 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/DataState.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | sealed class DataState { 6 | object Empty : DataState() 7 | object Loading : DataState() 8 | 9 | data class Error( 10 | val exception: Exception 11 | ) : DataState() 12 | 13 | data class Success( 14 | val data: T 15 | ) : DataState() 16 | 17 | fun read(): T = (this as Success).data 18 | 19 | fun readSafely(): T? = if (this is Success) read() else null 20 | 21 | val notLoaded: Boolean 22 | get() = this is Empty || this is Error 23 | 24 | /** 25 | * 可视化DataState 26 | */ 27 | @Composable 28 | inline fun Display( 29 | empty: @Composable () -> Unit = {}, 30 | loading: @Composable () -> Unit = {}, 31 | error: @Composable (Exception) -> Unit = {}, 32 | success: @Composable (T) -> Unit 33 | ) { 34 | when(this){ 35 | is Success -> success(read()) 36 | is Error -> error(exception) 37 | is Loading -> loading() 38 | is Empty -> empty() 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/PersonalizedPlaylist.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class PersonalizedPlaylist( 7 | @SerializedName("category") 8 | val category: Int, 9 | @SerializedName("code") 10 | val code: Int, 11 | @SerializedName("hasTaste") 12 | val hasTaste: Boolean, 13 | @SerializedName("result") 14 | val result: List 15 | ) { 16 | data class Result( 17 | @SerializedName("alg") 18 | val alg: String, 19 | @SerializedName("canDislike") 20 | val canDislike: Boolean, 21 | @SerializedName("copywriter") 22 | val copywriter: Any, 23 | @SerializedName("highQuality") 24 | val highQuality: Boolean, 25 | @SerializedName("id") 26 | val id: Long, 27 | @SerializedName("name") 28 | val name: String, 29 | @SerializedName("picUrl") 30 | val picUrl: String, 31 | @SerializedName("playCount") 32 | val playCount: Double, 33 | @SerializedName("trackCount") 34 | val trackCount: Int, 35 | @SerializedName("trackNumberUpdateTime") 36 | val trackNumberUpdateTime: Long, 37 | @SerializedName("type") 38 | val type: Int 39 | ) 40 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/component/RequireLogin.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import me.rerere.rainmusic.data.model.UserData 10 | import me.rerere.rainmusic.ui.local.LocalUserData 11 | 12 | /** 13 | * 只在登录后显示的内容 14 | * 15 | * @param content 内容 16 | */ 17 | @Composable 18 | fun RequireLoginVisible( 19 | notLoginUI: @Composable () -> Unit = { 20 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center){ 21 | Text(text = "此功能需要登录") 22 | } 23 | }, 24 | content: @Composable () -> Unit 25 | ) { 26 | val userData = LocalUserData.current 27 | if(!userData.isVisitor){ 28 | content() 29 | } else { 30 | notLoginUI() 31 | } 32 | } 33 | 34 | /** 35 | * 根据是否登录执行不同的动作,通常用于 clickable() 中 36 | * 37 | * @param notLoginAction 未登录时执行的操作 38 | * @param loginAction 已登录后执行的操作 39 | */ 40 | inline fun UserData.handle( 41 | notLoginAction: () -> Unit, 42 | loginAction: () -> Unit 43 | ) { 44 | if(isVisitor){ 45 | notLoginAction() 46 | } else { 47 | loginAction() 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/Screen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen 2 | 3 | import androidx.navigation.NavController 4 | 5 | sealed class Screen(val route: String) { 6 | object Login : Screen("login") 7 | object Index : Screen("index") 8 | object Search : Screen("search") 9 | object Playlist : Screen("playlist") 10 | object Player : Screen("player") 11 | object DailySong: Screen("dailysong") 12 | object Test : Screen("test") 13 | 14 | inline fun navigate( 15 | navController: NavController, 16 | builder: NavigationBuilder.() -> Unit = {} 17 | ) { 18 | navController.navigate(NavigationBuilder(route).apply(builder).build()) 19 | } 20 | } 21 | 22 | class NavigationBuilder( 23 | route: String 24 | ) { 25 | private var finalRoute: String = route 26 | private val query: MutableMap = hashMapOf() 27 | 28 | fun addPath(path: String) { 29 | finalRoute += "/$path" 30 | } 31 | 32 | fun addQuery(key: String, value: String) { 33 | query += key to value 34 | } 35 | 36 | fun build(): String = if (query.isEmpty()) { 37 | finalRoute 38 | } else { 39 | "$finalRoute${ 40 | query.entries.joinToString( 41 | separator = "&", 42 | prefix = "?" 43 | ) { "${it.key}=${it.value}" } 44 | }" 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/NavigationUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.animation.core.tween 5 | import androidx.navigation.NavBackStackEntry 6 | 7 | @ExperimentalAnimationApi 8 | val defaultEnterTransition: (AnimatedContentScope.() -> EnterTransition) = { 9 | slideInHorizontally( 10 | initialOffsetX = { 11 | it 12 | }, 13 | animationSpec = tween() 14 | ) 15 | } 16 | 17 | @ExperimentalAnimationApi 18 | val defaultExitTransition : (AnimatedContentScope.() -> ExitTransition) = { 19 | slideOutHorizontally( 20 | targetOffsetX = { 21 | -it 22 | }, 23 | animationSpec = tween() 24 | ) + fadeOut( 25 | animationSpec = tween() 26 | ) 27 | } 28 | 29 | @ExperimentalAnimationApi 30 | val defaultPopEnterTransition : (AnimatedContentScope.() -> EnterTransition) = { 31 | slideInHorizontally( 32 | initialOffsetX = { 33 | -it 34 | }, 35 | animationSpec = tween() 36 | ) 37 | } 38 | 39 | @ExperimentalAnimationApi 40 | val defaultPopExitTransition: (AnimatedContentScope.() -> ExitTransition) = { 41 | slideOutHorizontally( 42 | targetOffsetX = { 43 | it 44 | }, 45 | animationSpec = tween() 46 | ) 47 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | # Enables namespacing of each library's R class so that its R class includes only the 23 | # resources declared in the library itself and none from the library's dependencies, 24 | # thereby reducing the size of the R class for that library 25 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | import me.rerere.rainmusic.repo.UserRepo 10 | import me.rerere.rainmusic.util.DataState 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class LoginViewModel @Inject constructor( 15 | private val userRepo: UserRepo 16 | ) : ViewModel() { 17 | /** 18 | * 登录状态 19 | * 20 | * -1: 异常 21 | * 0: idle 22 | * 1: 登录中 23 | * 2: 密码错误 24 | * 3: 账号不存在 25 | * 26 | * 1000: 登录成功 27 | */ 28 | val loginState = MutableStateFlow(0) 29 | 30 | fun loginCellPhone( 31 | phone: String, 32 | password: String 33 | ) { 34 | userRepo.loginCellPhone( 35 | phone, 36 | password 37 | ).onEach { 38 | loginState.value = when (it) { 39 | is DataState.Success -> when (it.data.code) { 40 | 200 -> 1000 41 | 502 -> 2 42 | 501 -> 3 43 | else -> -1 44 | } 45 | is DataState.Loading -> 1 46 | is DataState.Error -> -1 47 | is DataState.Empty -> 0 48 | } 49 | }.launchIn(viewModelScope) 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/playlist/PlaylistViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.playlist 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.launchIn 9 | import kotlinx.coroutines.flow.onEach 10 | import kotlinx.coroutines.launch 11 | import me.rerere.rainmusic.data.retrofit.api.model.PlaylistDetail 12 | import me.rerere.rainmusic.repo.MusicRepo 13 | import me.rerere.rainmusic.util.DataState 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class PlaylistViewModel @Inject constructor( 18 | private val musicRepo: MusicRepo 19 | ) : ViewModel(){ 20 | val playlistDetail : MutableStateFlow> = MutableStateFlow(DataState.Empty) 21 | 22 | fun loadPlaylist(id: Long){ 23 | musicRepo.getPlaylistDetail(id) 24 | .onEach { 25 | playlistDetail.value = it 26 | } 27 | .launchIn(viewModelScope) 28 | } 29 | 30 | fun subscribe(scope: CoroutineScope) { 31 | if(playlistDetail.value is DataState.Success) { 32 | scope.launch { 33 | val id = playlistDetail.value.read().playlist.id 34 | musicRepo.subPlaylist( 35 | playlistId = id, 36 | sub = !playlistDetail.value.read().playlist.subscribed 37 | )?.takeIf { it.code == 200 }?.let { 38 | loadPlaylist(id) 39 | } 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/paging/TopPlaylistPagingSource.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.paging 2 | 3 | import android.util.Log 4 | import androidx.paging.PagingSource 5 | import androidx.paging.PagingState 6 | import me.rerere.rainmusic.data.model.Playlists 7 | import me.rerere.rainmusic.data.retrofit.weapi.model.TopPlaylists 8 | import me.rerere.rainmusic.repo.MusicRepo 9 | import me.rerere.rainmusic.util.DataState 10 | 11 | private const val TAG = "TopPlaylistPagingSource" 12 | 13 | class TopPlaylistPagingSource( 14 | private val category: String, 15 | private val musicRepo: MusicRepo 16 | ) : PagingSource() { 17 | override fun getRefreshKey(state: PagingState): Int { 18 | return 0 19 | } 20 | 21 | override suspend fun load(params: LoadParams): LoadResult { 22 | val page = params.key ?: 0 23 | var state: DataState = DataState.Loading 24 | 25 | Log.i(TAG, "load: loading playlist[$page] of $category") 26 | 27 | musicRepo.getTopPlaylist( 28 | category = category, 29 | offset = page * params.loadSize, 30 | limit = params.loadSize 31 | ).collect { 32 | state = it 33 | } 34 | 35 | return when(state){ 36 | is DataState.Success -> LoadResult.Page( 37 | data = state.read().playlists ?: emptyList(), 38 | prevKey = if(page == 0) null else page - 1, 39 | nextKey = if(state.read().more) page + 1 else null 40 | ) 41 | is DataState.Error -> LoadResult.Error((state as DataState.Error).exception) 42 | else -> LoadResult.Invalid() 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/PlaylistCategory.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class PlaylistCategory( 7 | @SerializedName("all") 8 | val all: All, 9 | @SerializedName("categories") 10 | val categories: Map, 11 | @SerializedName("code") 12 | val code: Int, 13 | @SerializedName("sub") 14 | val sub: List 15 | ) { 16 | data class All( 17 | @SerializedName("activity") 18 | val activity: Boolean, 19 | @SerializedName("category") 20 | val category: Int, 21 | @SerializedName("hot") 22 | val hot: Boolean, 23 | @SerializedName("imgId") 24 | val imgId: Int, 25 | @SerializedName("imgUrl") 26 | val imgUrl: Any, 27 | @SerializedName("name") 28 | val name: String, 29 | @SerializedName("resourceCount") 30 | val resourceCount: Int, 31 | @SerializedName("resourceType") 32 | val resourceType: Int, 33 | @SerializedName("type") 34 | val type: Int 35 | ) 36 | 37 | data class Sub( 38 | @SerializedName("activity") 39 | val activity: Boolean, 40 | @SerializedName("category") 41 | val category: Int, 42 | @SerializedName("hot") 43 | val hot: Boolean, 44 | @SerializedName("imgId") 45 | val imgId: Int, 46 | @SerializedName("imgUrl") 47 | val imgUrl: Any, 48 | @SerializedName("name") 49 | val name: String, 50 | @SerializedName("resourceCount") 51 | val resourceCount: Int, 52 | @SerializedName("resourceType") 53 | val resourceType: Int, 54 | @SerializedName("type") 55 | val type: Int 56 | ) 57 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/states/Media3Session.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.states 2 | 3 | import android.content.ComponentName 4 | import android.content.Context 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.platform.LocalContext 7 | import androidx.media3.session.MediaController 8 | import androidx.media3.session.SessionToken 9 | import com.google.common.util.concurrent.MoreExecutors 10 | 11 | /** 12 | * 提供对应MediaSessionService的MediaController状态, 未加载完成时 13 | * state内为null 14 | * 15 | * @return MediaController状态(可空) 16 | */ 17 | @Composable 18 | fun rememberMediaSessionPlayer(clazz: Class): State { 19 | val context = LocalContext.current 20 | val controller = remember(context) { 21 | mutableStateOf(null) 22 | } 23 | DisposableEffect(context) { 24 | val builder = MediaController.Builder( 25 | context, 26 | SessionToken( 27 | context, 28 | ComponentName( 29 | context, 30 | clazz 31 | ) 32 | ) 33 | ).buildAsync() 34 | 35 | builder.addListener({ 36 | controller.value = builder.get() 37 | }, MoreExecutors.directExecutor()) 38 | 39 | onDispose { 40 | MediaController.releaseFuture(builder) 41 | } 42 | } 43 | return controller 44 | } 45 | 46 | fun Context.asyncGetSessionPlayer(clazz: Class, handler: (MediaController) -> Unit) { 47 | val controller = MediaController.Builder( 48 | this, 49 | SessionToken( 50 | this, 51 | ComponentName( 52 | this, 53 | clazz 54 | ) 55 | ) 56 | ).buildAsync() 57 | 58 | controller.addListener({ 59 | handler(controller.get()) 60 | }, MoreExecutors.directExecutor()) 61 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/search/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.search 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.OutlinedTextField 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.rounded.Search 10 | import androidx.compose.material3.* 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import me.rerere.rainmusic.ui.component.PopBackIcon 15 | import me.rerere.rainmusic.ui.component.RainTopBar 16 | 17 | @ExperimentalMaterial3Api 18 | @Composable 19 | fun SearchScreen() { 20 | Scaffold( 21 | topBar = { 22 | RainTopBar( 23 | navigationIcon = { 24 | PopBackIcon() 25 | }, 26 | title = { 27 | Text(text = "搜索") 28 | } 29 | ) 30 | } 31 | ) { 32 | Body() 33 | } 34 | } 35 | 36 | @Composable 37 | private fun Body() { 38 | Column { 39 | var query by remember { 40 | mutableStateOf("") 41 | } 42 | OutlinedTextField( 43 | modifier = Modifier 44 | .fillMaxWidth() 45 | .padding(12.dp), 46 | value = query, 47 | onValueChange = { 48 | query = it 49 | }, 50 | placeholder = { 51 | Text(text = "尝试搜索一下吧 (●'◡'●)") 52 | }, 53 | trailingIcon = { 54 | IconButton(onClick = { /*TODO*/ }) { 55 | Icon(Icons.Rounded.Search, null) 56 | } 57 | } 58 | ) 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/HotPlaylistTag.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class HotPlaylistTag( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("tags") 10 | val tags: List 11 | ) { 12 | data class Tag( 13 | @SerializedName("activity") 14 | val activity: Boolean, 15 | @SerializedName("category") 16 | val category: Int, 17 | @SerializedName("createTime") 18 | val createTime: Long, 19 | @SerializedName("hot") 20 | val hot: Boolean, 21 | @SerializedName("id") 22 | val id: Int, 23 | @SerializedName("name") 24 | val name: String, 25 | @SerializedName("playlistTag") 26 | val playlistTag: PlaylistTag, 27 | @SerializedName("position") 28 | val position: Int, 29 | @SerializedName("type") 30 | val type: Int, 31 | @SerializedName("usedCount") 32 | val usedCount: Int 33 | ) { 34 | data class PlaylistTag( 35 | @SerializedName("category") 36 | val category: Int, 37 | @SerializedName("createTime") 38 | val createTime: Long, 39 | @SerializedName("highQuality") 40 | val highQuality: Int, 41 | @SerializedName("highQualityPos") 42 | val highQualityPos: Int, 43 | @SerializedName("id") 44 | val id: Int, 45 | @SerializedName("name") 46 | val name: String, 47 | @SerializedName("officialPos") 48 | val officialPos: Int, 49 | @SerializedName("position") 50 | val position: Int, 51 | @SerializedName("type") 52 | val type: Int, 53 | @SerializedName("usedCount") 54 | val usedCount: Int 55 | ) 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/repo/YiYanRepo.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.repo 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.annotations.SerializedName 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.withContext 8 | import me.rerere.rainmusic.util.DataState 9 | import okhttp3.OkHttpClient 10 | import okhttp3.Request 11 | import javax.inject.Inject 12 | 13 | class YiYanRepo @Inject constructor( 14 | private val okHttpClient: OkHttpClient 15 | ) { 16 | fun loadYiYan() = flow { 17 | emit(DataState.Loading) 18 | try { 19 | val request = Request.Builder() 20 | .url("https://v1.hitokoto.cn") 21 | .get() 22 | .build() 23 | val result = withContext(Dispatchers.IO) { 24 | okHttpClient.newCall(request).execute() 25 | } 26 | require(result.isSuccessful) 27 | val text = result.body.string() ?: "" 28 | emit(DataState.Success(Gson().fromJson(text, YiYan::class.java))) 29 | } catch (e: Exception) { 30 | e.printStackTrace() 31 | emit(DataState.Error(e)) 32 | } 33 | } 34 | } 35 | 36 | data class YiYan( 37 | @SerializedName("commit_from") 38 | val commitFrom: String, 39 | @SerializedName("created_at") 40 | val createdAt: String, 41 | @SerializedName("creator") 42 | val creator: String, 43 | @SerializedName("creator_uid") 44 | val creatorUid: Int, 45 | @SerializedName("from") 46 | val from: String, 47 | @SerializedName("from_who") 48 | val fromWho: Any, 49 | @SerializedName("hitokoto") 50 | val hitokoto: String, 51 | @SerializedName("id") 52 | val id: Int, 53 | @SerializedName("length") 54 | val length: Int, 55 | @SerializedName("reviewer") 56 | val reviewer: Int, 57 | @SerializedName("type") 58 | val type: String, 59 | @SerializedName("uuid") 60 | val uuid: String 61 | ) -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.SideEffect 9 | import androidx.compose.ui.graphics.Color 10 | import androidx.compose.ui.graphics.toArgb 11 | import androidx.compose.ui.platform.LocalContext 12 | import androidx.compose.ui.platform.LocalView 13 | import androidx.core.view.WindowCompat 14 | import me.rerere.rainmusic.util.findActivity 15 | 16 | // 自定义colorScheme 17 | val LightColorScheme = lightColorScheme() 18 | val DarkColorScheme = darkColorScheme() 19 | 20 | @Composable 21 | fun RainMusicTheme( 22 | darkTheme: Boolean = isSystemInDarkTheme(), 23 | dynamicColor: Boolean = true, 24 | content: @Composable () -> Unit 25 | ) { 26 | val colorScheme = when { 27 | // Android 12, 动态壁纸取色 28 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 29 | val context = LocalContext.current 30 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 31 | } 32 | darkTheme -> DarkColorScheme 33 | else -> LightColorScheme 34 | } 35 | 36 | ApplyBarColor(darkTheme) 37 | MaterialTheme( 38 | colorScheme = colorScheme, 39 | typography = Typography, 40 | content = content 41 | ) 42 | } 43 | 44 | @Composable 45 | fun ApplyBarColor(darkTheme: Boolean) { 46 | val view = LocalView.current 47 | val activity = LocalContext.current as Activity 48 | SideEffect { 49 | view.context.findActivity().window.apply { 50 | statusBarColor = Color.Transparent.toArgb() 51 | navigationBarColor = Color.Transparent.toArgb() 52 | } 53 | WindowCompat.getInsetsController(activity.window, view).apply { 54 | isAppearanceLightNavigationBars = !darkTheme 55 | isAppearanceLightStatusBars = !darkTheme 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/states/PlayerStates.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.states 2 | 3 | import androidx.compose.runtime.* 4 | import androidx.media3.common.MediaItem 5 | import androidx.media3.common.Player 6 | import kotlinx.coroutines.isActive 7 | 8 | @Composable 9 | fun rememberCurrentMediaItem(player: Player?): MediaItem? { 10 | var mediaItemState by remember(player) { 11 | mutableStateOf(player?.currentMediaItem) 12 | } 13 | DisposableEffect(player) { 14 | val listener = object : Player.Listener { 15 | override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { 16 | println("item trans: ${mediaItem?.mediaId} reason: $reason") 17 | mediaItemState = mediaItem 18 | } 19 | } 20 | player?.addListener(listener) 21 | onDispose { 22 | player?.removeListener(listener) 23 | } 24 | } 25 | return mediaItemState 26 | } 27 | 28 | @Composable 29 | fun rememberPlayProgress(player: Player?): Pair? { 30 | return produceState( 31 | initialValue = player?.let { player.currentPosition to player.duration }, 32 | key1 = player 33 | ){ 34 | while (isActive) { 35 | value = player?.let { 36 | if(player.currentMediaItem == null){ 37 | 0L to 1L 38 | } else { 39 | player.currentPosition to player.duration.coerceAtLeast(1) 40 | } 41 | } 42 | kotlinx.coroutines.delay(500) 43 | } 44 | }.value 45 | } 46 | 47 | @Composable 48 | fun rememberPlayState(player: Player?): Boolean? { 49 | var isPlayingState by remember(player) { 50 | mutableStateOf(player?.isPlaying) 51 | } 52 | DisposableEffect(player) { 53 | val listener = object : Player.Listener { 54 | override fun onIsPlayingChanged(isPlaying: Boolean) { 55 | isPlayingState = isPlaying 56 | } 57 | } 58 | player?.addListener(listener) 59 | onDispose { 60 | player?.removeListener(listener) 61 | } 62 | } 63 | return isPlayingState 64 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/NeteaseMusicApi.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api 2 | 3 | import com.google.gson.JsonObject 4 | import me.rerere.rainmusic.data.retrofit.api.model.* 5 | import retrofit2.http.* 6 | 7 | interface NeteaseMusicApi { 8 | /** 9 | * 获取自己的账号信息 10 | */ 11 | @GET("/api/nuser/account/get") 12 | suspend fun getAccountDetail(): AccountDetail 13 | 14 | /** 15 | * 获取歌单信息 16 | */ 17 | @POST("/api/v6/playlist/detail") 18 | @FormUrlEncoded 19 | suspend fun getPlaylistDetail( 20 | @FieldMap body: Map 21 | ): PlaylistDetail 22 | 23 | /** 24 | * 获取歌曲详情 25 | */ 26 | @POST("/api/v3/song/detail") 27 | @FormUrlEncoded 28 | suspend fun getMusicDetail( 29 | @FieldMap body: Map 30 | ): MusicDetails 31 | 32 | /** 33 | * 获取歌词 34 | */ 35 | @POST("/api/song/lyric") 36 | @FormUrlEncoded 37 | suspend fun getLyric( 38 | @FieldMap body: Map 39 | ): Lyric 40 | 41 | /** 42 | * 获取用户的歌单 43 | */ 44 | @POST("/api/user/playlist") 45 | @FormUrlEncoded 46 | suspend fun getUserPlaylist( 47 | @FieldMap body: Map 48 | ): UserPlaylists 49 | 50 | /** 51 | * 获取榜单 52 | */ 53 | @GET("/api/toplist") 54 | suspend fun getAllTopList(): Toplists 55 | 56 | /** 57 | * 每日推荐歌曲 58 | */ 59 | @GET("/api/v3/discovery/recommend/songs") 60 | suspend fun getDailyRecommendSongList(): DailyRecommendSongs 61 | 62 | /** 63 | * 获取精品歌单 64 | */ 65 | @POST("/api/playlist/highquality/list") 66 | @FormUrlEncoded 67 | suspend fun getHighQualityPlaylist( 68 | @FieldMap body: Map 69 | ): HighQualityPlaylist 70 | 71 | @POST("/api/radio/like") 72 | @FormUrlEncoded 73 | suspend fun like( 74 | @Header("like") like: Boolean, 75 | @FieldMap body: Map 76 | ): LikeResult 77 | 78 | /** 79 | * 操作歌单 80 | */ 81 | @POST("/api/playlist/manipulate/tracks") 82 | @FormUrlEncoded 83 | suspend fun manipulatePlaylist( 84 | @FieldMap body: Map 85 | ): JsonObject 86 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/okhttp/CookieHelper.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.okhttp 2 | 3 | import android.util.Log 4 | import androidx.core.content.edit 5 | import me.rerere.rainmusic.util.sharedPreferencesOf 6 | import okhttp3.Cookie 7 | import okhttp3.CookieJar 8 | import okhttp3.HttpUrl 9 | 10 | private const val TAG = "CookieHelper" 11 | 12 | object CookieHelper : CookieJar { 13 | fun logout(){ 14 | sharedPreferencesOf("cookie").edit { 15 | clear() 16 | } 17 | } 18 | 19 | override fun loadForRequest(url: HttpUrl): List { 20 | val cookies = sharedPreferencesOf("cookie").all.map { (k, v) -> 21 | Cookie.Builder() 22 | .domain("music.163.com") 23 | .name(k) 24 | .value(v.toString()) 25 | .build() 26 | }.toMutableList() 27 | if (!cookies.any { 28 | it.name == "os" 29 | }) { 30 | cookies += Cookie.Builder() 31 | .domain("music.163.com") 32 | .name("os") 33 | .value("pc") 34 | .build() 35 | } 36 | if (!cookies.any { 37 | it.name == "appver" 38 | }) { 39 | cookies += Cookie.Builder() 40 | .domain("music.163.com") 41 | .name("appver") 42 | .value("2.7.1.198277") 43 | .build() 44 | } 45 | return cookies.also { 46 | Log.d(TAG, "loadForRequest: ${cookies.joinToString(separator = ","){it.name}}") 47 | } 48 | } 49 | 50 | override fun saveFromResponse(url: HttpUrl, cookies: List) { 51 | sharedPreferencesOf("cookie").let { 52 | cookies 53 | .filter { 54 | it.domain == "music.163.com" 55 | } 56 | .forEach { cookie -> 57 | it.edit { 58 | putString(cookie.name, cookie.value) 59 | Log.i( 60 | TAG, 61 | "saveFromResponse: saved cookie: ${cookie.name}=${cookie.value}" 62 | ) 63 | } 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/eapi/model/MusicUrl.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.eapi.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class MusicUrl( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("data") 10 | val `data`: List 11 | ) { 12 | data class Data( 13 | @SerializedName("br") 14 | val br: Int, 15 | @SerializedName("canExtend") 16 | val canExtend: Boolean, 17 | @SerializedName("code") 18 | val code: Int, 19 | @SerializedName("encodeType") 20 | val encodeType: Any, 21 | @SerializedName("expi") 22 | val expi: Int, 23 | @SerializedName("fee") 24 | val fee: Int, 25 | @SerializedName("flag") 26 | val flag: Int, 27 | @SerializedName("freeTimeTrialPrivilege") 28 | val freeTimeTrialPrivilege: FreeTimeTrialPrivilege, 29 | @SerializedName("freeTrialInfo") 30 | val freeTrialInfo: Any, 31 | @SerializedName("freeTrialPrivilege") 32 | val freeTrialPrivilege: FreeTrialPrivilege, 33 | @SerializedName("gain") 34 | val gain: Double, 35 | @SerializedName("id") 36 | val id: Int, 37 | @SerializedName("level") 38 | val level: Any, 39 | @SerializedName("md5") 40 | val md5: String, 41 | @SerializedName("payed") 42 | val payed: Int, 43 | @SerializedName("size") 44 | val size: Int, 45 | @SerializedName("type") 46 | val type: String, 47 | @SerializedName("uf") 48 | val uf: Any, 49 | @SerializedName("url") 50 | val url: String, 51 | @SerializedName("urlSource") 52 | val urlSource: Int 53 | ) { 54 | data class FreeTimeTrialPrivilege( 55 | @SerializedName("remainTime") 56 | val remainTime: Int, 57 | @SerializedName("resConsumable") 58 | val resConsumable: Boolean, 59 | @SerializedName("type") 60 | val type: Int, 61 | @SerializedName("userConsumable") 62 | val userConsumable: Boolean 63 | ) 64 | 65 | data class FreeTrialPrivilege( 66 | @SerializedName("resConsumable") 67 | val resConsumable: Boolean, 68 | @SerializedName("userConsumable") 69 | val userConsumable: Boolean 70 | ) 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import me.rerere.rainmusic.data.retrofit.api.NeteaseMusicApi 8 | import me.rerere.rainmusic.data.retrofit.eapi.NeteaseMusicEApi 9 | import me.rerere.rainmusic.data.retrofit.weapi.NeteaseMusicWeApi 10 | import me.rerere.rainmusic.util.okhttp.CookieHelper 11 | import me.rerere.rainmusic.util.okhttp.EnsureHttpsInterceptor 12 | import me.rerere.rainmusic.util.okhttp.UserAgentInterceptor 13 | import okhttp3.OkHttpClient 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.gson.GsonConverterFactory 16 | import java.util.concurrent.TimeUnit 17 | import javax.inject.Singleton 18 | 19 | @Module 20 | @InstallIn(SingletonComponent::class) 21 | object NetworkModule { 22 | @Singleton 23 | @Provides 24 | fun provideHttpClient() = OkHttpClient.Builder() 25 | .connectTimeout(15, TimeUnit.SECONDS) 26 | .readTimeout(5, TimeUnit.SECONDS) 27 | .writeTimeout(5, TimeUnit.SECONDS) 28 | .retryOnConnectionFailure(true) 29 | .addInterceptor(EnsureHttpsInterceptor()) 30 | .addInterceptor(UserAgentInterceptor()) // user-agent 拦截 31 | .cookieJar(CookieHelper) // cookie 32 | .build() 33 | 34 | @Singleton 35 | @Provides 36 | fun provideRetrofitClient( 37 | okHttpClient: OkHttpClient 38 | ): Retrofit = Retrofit.Builder() 39 | .addConverterFactory(GsonConverterFactory.create()) 40 | .baseUrl("https://music.163.com") 41 | .client(okHttpClient) 42 | .build() 43 | 44 | @Singleton 45 | @Provides 46 | fun provideNeteaseWeApi( 47 | retrofit: Retrofit 48 | ): NeteaseMusicWeApi = retrofit.create( 49 | NeteaseMusicWeApi::class.java 50 | ) 51 | 52 | @Singleton 53 | @Provides 54 | fun provideNeteaseApi( 55 | retrofit: Retrofit 56 | ): NeteaseMusicApi = retrofit.create( 57 | NeteaseMusicApi::class.java 58 | ) 59 | 60 | @Singleton 61 | @Provides 62 | fun provideNeteaseEApi(okHttpClient: OkHttpClient): NeteaseMusicEApi = Retrofit.Builder() 63 | .addConverterFactory(GsonConverterFactory.create()) 64 | .baseUrl("https://interface3.music.163.com") 65 | .client(okHttpClient) 66 | .build() 67 | .create(NeteaseMusicEApi::class.java) 68 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/encrypt/Encryptor.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util.encrypt 2 | 3 | import com.google.gson.JsonObject 4 | import com.soywiz.krypto.AES 5 | import com.soywiz.krypto.Padding 6 | import com.soywiz.krypto.encoding.hex 7 | import com.soywiz.krypto.encoding.toBase64 8 | import com.soywiz.krypto.md5 9 | import okhttp3.FormBody 10 | 11 | private const val presetKey = "0CoJUm6Qyw8W8jud" 12 | private const val iv = "0102030405060708" 13 | private const val eapiKey = "e82ckenh8dichen8" 14 | 15 | /** 16 | * weapi 接口加密 17 | * 18 | * @param data 原始post请求数据 19 | * @return 加密后的post body 20 | */ 21 | fun encryptWeAPI( 22 | data: Map = emptyMap() 23 | ) : Map { 24 | val rawJson = JsonObject().apply { 25 | data.forEach { (t, u) -> 26 | addProperty(t, u) 27 | } 28 | }.toString() 29 | 30 | val key = createRandomKey() 31 | 32 | return mapOf( 33 | "params" to AES.encryptAesCbc( 34 | data = AES.encryptAesCbc( 35 | data = rawJson.toByteArray(), 36 | key = presetKey.toByteArray(), 37 | iv = iv.toByteArray(), 38 | padding = Padding.PKCS7Padding 39 | ).toBase64().toByteArray(), 40 | key = key.toByteArray(), 41 | iv = iv.toByteArray(), 42 | padding = Padding.PKCS7Padding 43 | ).toBase64(), 44 | "encSecKey" to rsaEncrypt( 45 | key 46 | ) 47 | ) 48 | } 49 | 50 | fun encryptEApi( 51 | url: String, 52 | data: Map = emptyMap() 53 | ) : Map { 54 | val rawJson = JsonObject().apply { 55 | data.forEach { (t, u) -> 56 | addProperty(t, u) 57 | } 58 | }.toString() 59 | val message = "nobody" + url + "use" + rawJson + "md5forencrypt" 60 | val digest: String = message.toByteArray().md5().hex 61 | return mapOf( 62 | "params" to AES.encryptAesEcb( 63 | data = "$url-36cd479b6b5-$rawJson-36cd479b6b5-$digest".toByteArray(), 64 | key = eapiKey.toByteArray(), 65 | padding = Padding.PKCS7Padding 66 | ).hex 67 | ) 68 | } 69 | 70 | fun createRandomKey() = StringBuilder().apply { 71 | repeat(16){ 72 | append((('a'..'z') + ('A'..'Z') + ('0'..'9')).random()) 73 | } 74 | }.toString() 75 | 76 | fun Map.asPostBody() = FormBody.Builder() 77 | .apply { 78 | this@asPostBody.forEach { (k, v) -> 79 | add(k, v) 80 | } 81 | } 82 | .build() -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/player/PlayerScreenViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.player 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.launchIn 8 | import kotlinx.coroutines.flow.onEach 9 | import kotlinx.coroutines.launch 10 | import me.rerere.rainmusic.data.retrofit.api.model.Lyric 11 | import me.rerere.rainmusic.data.retrofit.api.model.MusicDetails 12 | import me.rerere.rainmusic.data.retrofit.weapi.model.LikeList 13 | import me.rerere.rainmusic.repo.MusicRepo 14 | import me.rerere.rainmusic.repo.UserRepo 15 | import me.rerere.rainmusic.util.DataState 16 | import javax.inject.Inject 17 | 18 | @HiltViewModel 19 | class PlayerScreenViewModel @Inject constructor( 20 | private val musicRepo: MusicRepo, 21 | private val userRepo: UserRepo 22 | ) : ViewModel() { 23 | val likeList: MutableStateFlow> = MutableStateFlow(DataState.Empty) 24 | val musicDetail: MutableStateFlow> = MutableStateFlow(DataState.Empty) 25 | val lyric: MutableStateFlow> = MutableStateFlow(DataState.Empty) 26 | 27 | fun like(uid: Long) { 28 | viewModelScope.launch { 29 | if (musicDetail.value is DataState.Success && likeList.value is DataState.Success) { 30 | val id = musicDetail.value.read().songs[0].id 31 | val like = likeList.value.read().ids.contains(id) 32 | musicRepo.likeMusic( 33 | musicId = id, 34 | like = !like 35 | )?.let { 36 | if(it.code == 200) { 37 | loadLikeList(uid) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | fun loadLikeList(uid: Long) { 45 | if (uid <= 0) { 46 | return 47 | } 48 | userRepo.getLikeList(uid).onEach { 49 | likeList.value = it 50 | }.launchIn(viewModelScope) 51 | } 52 | 53 | fun loadMusicDetail(id: Long) { 54 | if (id == 0L) { 55 | musicDetail.value = DataState.Empty 56 | lyric.value = DataState.Empty 57 | return 58 | } 59 | 60 | musicRepo.getMusicDetail(id) 61 | .onEach { 62 | musicDetail.value = it 63 | }.launchIn(viewModelScope) 64 | 65 | musicRepo.getLyric(id) 66 | .onEach { 67 | lyric.value = it 68 | }.launchIn(viewModelScope) 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/NeteaseMusicWeApi.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi 2 | 3 | import com.google.gson.JsonObject 4 | import me.rerere.rainmusic.data.retrofit.weapi.model.* 5 | import me.rerere.rainmusic.util.encrypt.encryptWeAPI 6 | import retrofit2.http.FieldMap 7 | import retrofit2.http.FormUrlEncoded 8 | import retrofit2.http.POST 9 | import retrofit2.http.Path 10 | 11 | /** 12 | * 网易云weapi接口 13 | * 访问此接口需要经过 [encryptWeAPI][me.rerere.rainmusic.util.encrypt.encryptWeAPI] 函数加密 14 | */ 15 | interface NeteaseMusicWeApi { 16 | /** 17 | * 登录网易云 18 | */ 19 | @POST("/weapi/login/cellphone") 20 | @FormUrlEncoded 21 | suspend fun loginCellphone( 22 | @FieldMap body: Map 23 | ) : LoginResponse 24 | 25 | /** 26 | * 刷新登录状态 27 | */ 28 | @POST("/weapi/login/token/refresh") 29 | @FormUrlEncoded 30 | suspend fun refreshLogin( 31 | @FieldMap body: Map = encryptWeAPI() 32 | ) : JsonObject 33 | 34 | /** 35 | * 推荐歌单 36 | */ 37 | @POST("/weapi/personalized/playlist") 38 | @FormUrlEncoded 39 | suspend fun personalizedPlaylist( 40 | @FieldMap body: Map 41 | ): PersonalizedPlaylist 42 | 43 | /** 44 | * 最新歌曲 45 | */ 46 | @POST("/weapi/personalized/newsong") 47 | @FormUrlEncoded 48 | suspend fun getNewSongs( 49 | @FieldMap body: Map 50 | ): NewSongs 51 | 52 | /** 53 | * 喜欢的歌曲列表 54 | */ 55 | @POST("/weapi/song/like/get") 56 | @FormUrlEncoded 57 | suspend fun getLikeList( 58 | @FieldMap body: Map 59 | ): LikeList 60 | 61 | /** 62 | * 签到 63 | */ 64 | @POST("/weapi/point/dailyTask") 65 | @FormUrlEncoded 66 | suspend fun dailySign( 67 | @FieldMap body: Map 68 | ): SignResult 69 | 70 | /** 71 | * 获取所有歌单种类 72 | */ 73 | @POST("/weapi/playlist/catalogue") 74 | @FormUrlEncoded 75 | suspend fun getPlaylistCat( 76 | @FieldMap body: Map 77 | ): PlaylistCategory 78 | 79 | /** 80 | * 获取种类下的歌单列表 81 | */ 82 | @POST("/weapi/playlist/list") 83 | @FormUrlEncoded 84 | suspend fun getTopPlaylist( 85 | @FieldMap body: Map 86 | ): TopPlaylists 87 | 88 | /** 89 | * 获取热门歌单tag 90 | */ 91 | @POST("/weapi/playlist/hottags") 92 | @FormUrlEncoded 93 | suspend fun getHotPlaylistTags( 94 | @FieldMap body: Map 95 | ): HotPlaylistTag 96 | 97 | /** 98 | * 收藏与取消收藏歌单 99 | */ 100 | @POST("/weapi/playlist/{action}") 101 | @FormUrlEncoded 102 | suspend fun subPlaylist( 103 | @Path("action") action: String, 104 | @FieldMap body: Map 105 | ): SubPlaylistResult 106 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/util/ContextUtil.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.util 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.app.NotificationChannel 6 | import android.app.NotificationManager 7 | import android.content.ClipData 8 | import android.content.ClipboardManager 9 | import android.content.Context 10 | import android.content.ContextWrapper 11 | import android.net.ConnectivityManager 12 | import android.net.NetworkCapabilities 13 | import android.os.Build 14 | import android.widget.Toast 15 | 16 | /** 17 | * 基于Context寻找Activity 18 | */ 19 | fun Context.findActivity(): Activity = when (this) { 20 | is Activity -> this 21 | is ContextWrapper -> baseContext.findActivity() 22 | else -> error("Failed to find activity: ${this.javaClass.name}") 23 | } 24 | 25 | /** 26 | * 创建通知渠道 27 | * 28 | * @param channelId 渠道ID 29 | * @param name 渠道名称 30 | * @param importance 渠道通知的重要程度 31 | * @param description 渠道描述 32 | */ 33 | @SuppressLint("WrongConstant") 34 | fun Context.createNotificationChannel( 35 | channelId: String, 36 | name: String, 37 | importance: Int = 2, // 2 = Low 38 | description: String? = null, 39 | ) { 40 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 41 | (this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).apply { 42 | createNotificationChannel( 43 | NotificationChannel( 44 | channelId, 45 | name, 46 | importance 47 | ).apply { 48 | description?.let { 49 | this.description = description 50 | } 51 | } 52 | ) 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * 发送toast通知 59 | * 60 | * @param text 通知内容 61 | * @param duration 通知长短 62 | */ 63 | fun Context.toast( 64 | text: String, 65 | duration: Int = Toast.LENGTH_SHORT 66 | ) { 67 | Toast.makeText( 68 | this, 69 | text, 70 | duration 71 | ).show() 72 | } 73 | 74 | /** 75 | * 判断网络是否是不计费网络 76 | */ 77 | fun Context.isFreeNetwork(): Boolean { 78 | val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager 79 | val activeNetwork = connectivityManager?.activeNetwork 80 | val networkCapabilities = connectivityManager?.getNetworkCapabilities(activeNetwork) 81 | return networkCapabilities?.let { 82 | when { 83 | // WIFI 84 | it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true 85 | // 以太网 86 | it.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true 87 | // 蜂窝 88 | it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> false 89 | // 未知 90 | else -> true 91 | } 92 | } ?: true 93 | } 94 | 95 | fun Context.setPaste(text: String) { 96 | val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager 97 | clipboardManager.setPrimaryClip( 98 | ClipData.newPlainText( 99 | text, 100 | text 101 | ) 102 | ) 103 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/component/Banner.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.component 2 | 3 | import android.content.Intent 4 | import android.provider.Settings 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.rounded.NetworkCheck 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.Surface 11 | import androidx.compose.material3.Text 12 | import androidx.compose.material3.TextButton 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.unit.dp 18 | 19 | /** 20 | * 一个Material Design Banner的简易实现 21 | * https://material.io/components/banners#usage 22 | * 23 | * @param icon Banner图标,可不写 24 | * @param text Banner文本区域 25 | * @param actions Banner按钮区域 26 | */ 27 | @Composable 28 | fun Banner( 29 | icon: @Composable (() -> Unit)? = null, 30 | text: @Composable () -> Unit, 31 | actions: @Composable RowScope.() -> Unit 32 | ) { 33 | Surface( 34 | modifier = Modifier 35 | .fillMaxWidth() 36 | .padding(8.dp), 37 | tonalElevation = 8.dp, 38 | shape = RoundedCornerShape(8.dp) 39 | ) { 40 | Column( 41 | modifier = Modifier.padding( 42 | start = 16.dp, 43 | top = 16.dp, 44 | end = 16.dp , 45 | bottom = 0.dp // 不需要给TextButton留Padding, 那样太丑 46 | ), 47 | verticalArrangement = Arrangement.spacedBy(8.dp) 48 | ) { 49 | Row( 50 | verticalAlignment = Alignment.CenterVertically, 51 | horizontalArrangement = Arrangement.spacedBy(16.dp) 52 | ) { 53 | if(icon != null){ 54 | icon() 55 | } 56 | text() 57 | } 58 | 59 | Row( 60 | modifier = Modifier.fillMaxWidth(), 61 | verticalAlignment = Alignment.CenterVertically, 62 | horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.End) 63 | ) { 64 | actions() 65 | } 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * 一个显示网络连接错误的Banner实现 72 | * 73 | * @param retryAction 重试操作 74 | */ 75 | @Composable 76 | internal fun NetworkIssueBanner( 77 | retryAction: () -> Unit 78 | ) { 79 | val context = LocalContext.current 80 | Banner( 81 | icon = { 82 | Icon(Icons.Rounded.NetworkCheck, null) 83 | }, 84 | text = { 85 | Text(text = "APP似乎无法连接到服务器,请检查你的网络连接状况") 86 | } 87 | ) { 88 | TextButton(onClick = { 89 | val intent = Intent(Settings.ACTION_WIRELESS_SETTINGS) 90 | context.startActivity(intent) 91 | }) { 92 | Text(text = "打开网络设置") 93 | } 94 | TextButton(onClick = { 95 | retryAction() 96 | }) { 97 | Text(text = "重试") 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/test/TestScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.test 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.TextField 6 | import androidx.compose.material3.Button 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.Scaffold 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.platform.LocalContext 13 | import androidx.hilt.navigation.compose.hiltViewModel 14 | import androidx.lifecycle.ViewModel 15 | import androidx.lifecycle.viewModelScope 16 | import com.soywiz.krypto.AES 17 | import com.soywiz.krypto.Padding 18 | import com.soywiz.krypto.encoding.unhex 19 | import dagger.hilt.android.lifecycle.HiltViewModel 20 | import kotlinx.coroutines.launch 21 | import me.rerere.rainmusic.data.retrofit.api.NeteaseMusicApi 22 | import me.rerere.rainmusic.data.retrofit.eapi.NeteaseMusicEApi 23 | import me.rerere.rainmusic.data.retrofit.weapi.NeteaseMusicWeApi 24 | import me.rerere.rainmusic.repo.MusicRepo 25 | import me.rerere.rainmusic.ui.component.PopBackIcon 26 | import me.rerere.rainmusic.ui.component.RainTopBar 27 | import me.rerere.rainmusic.ui.local.LocalUserData 28 | import me.rerere.rainmusic.util.setPaste 29 | import javax.inject.Inject 30 | 31 | @ExperimentalMaterial3Api 32 | @Composable 33 | fun TestScreen(testViewModel: TestViewModel = hiltViewModel()) { 34 | Scaffold( 35 | topBar = { 36 | RainTopBar( 37 | title = { 38 | Text(text = "测试页面") 39 | }, 40 | navigationIcon = { 41 | PopBackIcon() 42 | } 43 | ) 44 | } 45 | ) { 46 | val user = LocalUserData.current 47 | val context = LocalContext.current 48 | Column(Modifier.fillMaxSize()) { 49 | var content by remember { 50 | mutableStateOf("") 51 | } 52 | 53 | Button(onClick = { 54 | testViewModel.test { 55 | content = it 56 | } 57 | }) { 58 | Text(text = "Send") 59 | } 60 | 61 | Button(onClick = { 62 | 63 | context.setPaste(content) 64 | }) { 65 | Text(text = "Copy") 66 | } 67 | 68 | TextField(value = content, onValueChange = {content = it}) 69 | Button(onClick = { 70 | val decrypt = AES.decryptAesEcb( 71 | data = content.unhex, 72 | key = "e82ckenh8dichen8".toByteArray(), 73 | padding = Padding.PKCS7Padding 74 | ) 75 | println("解密结果: ${String(decrypt)}") 76 | }) { 77 | Text(text = "解密EAPI") 78 | } 79 | } 80 | } 81 | } 82 | 83 | @HiltViewModel 84 | class TestViewModel @Inject constructor( 85 | private val weApi: NeteaseMusicWeApi, 86 | private val api: NeteaseMusicApi, 87 | private val eApi: NeteaseMusicEApi, 88 | private val musicRepo: MusicRepo 89 | ) : ViewModel() { 90 | fun test(callback: (String) -> Unit){ 91 | viewModelScope.launch { 92 | 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/Lyric.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Lyric( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("klyric") 10 | val klyric: Klyric, 11 | @SerializedName("lrc") 12 | val lrc: Lrc, 13 | @SerializedName("lyricUser") 14 | val lyricUser: LyricUser, 15 | @SerializedName("qfy") 16 | val qfy: Boolean, 17 | @SerializedName("sfy") 18 | val sfy: Boolean, 19 | @SerializedName("sgc") 20 | val sgc: Boolean, 21 | @SerializedName("tlyric") 22 | val tlyric: Tlyric?, 23 | @SerializedName("transUser") 24 | val transUser: TransUser 25 | ) { 26 | data class Klyric( 27 | @SerializedName("lyric") 28 | val lyric: String, 29 | @SerializedName("version") 30 | val version: Int 31 | ) 32 | 33 | data class Lrc( 34 | @SerializedName("lyric") 35 | val lyric: String, 36 | @SerializedName("version") 37 | val version: Int 38 | ) 39 | 40 | data class LyricUser( 41 | @SerializedName("demand") 42 | val demand: Int, 43 | @SerializedName("id") 44 | val id: Long, 45 | @SerializedName("nickname") 46 | val nickname: String, 47 | @SerializedName("status") 48 | val status: Int, 49 | @SerializedName("uptime") 50 | val uptime: Long, 51 | @SerializedName("userid") 52 | val userid: Long 53 | ) 54 | 55 | data class Tlyric( 56 | @SerializedName("lyric") 57 | val lyric: String?, 58 | @SerializedName("version") 59 | val version: Int 60 | ) 61 | 62 | data class TransUser( 63 | @SerializedName("demand") 64 | val demand: Int, 65 | @SerializedName("id") 66 | val id: Int, 67 | @SerializedName("nickname") 68 | val nickname: String, 69 | @SerializedName("status") 70 | val status: Int, 71 | @SerializedName("uptime") 72 | val uptime: Long, 73 | @SerializedName("userid") 74 | val userid: Long 75 | ) 76 | } 77 | 78 | 79 | fun Lyric.parse(): List { 80 | val lines = lrc.lyric.split("\n") 81 | .filter { 82 | it.matches(Regex("\\[\\d+:\\d+.\\d+].+")) 83 | }.map { 84 | val minutes = it.substring(1 until (it.indexOf(":"))).toIntOrNull() ?: 0 85 | val seconds = 86 | it.substring((it.indexOf(":") + 1) until it.indexOf(".")).toIntOrNull() ?: 0 87 | val time = minutes * 60 + seconds 88 | LyricLine( 89 | time = time, 90 | lyric = it.substring(it.indexOf("]") + 1), 91 | translation = null 92 | ) 93 | } 94 | 95 | // 将翻译添加到歌词中 96 | tlyric?.lyric?.split("\n")?.filter { 97 | it.matches(Regex("\\[\\d+:\\d+.\\d+].+")) 98 | }?.forEach { 99 | val minutes = it.substring(1 until (it.indexOf(":"))).toIntOrNull() ?: 0 100 | val seconds = 101 | it.substring((it.indexOf(":") + 1) until it.indexOf(".")).toIntOrNull() ?: 0 102 | val time = minutes * 60 + seconds 103 | lines.find { lyric -> lyric.time == time && lyric.translation == null }?.translation = it.substring(it.indexOf("]") + 1) 104 | } 105 | return lines 106 | } 107 | 108 | data class LyricLine( 109 | val time: Int, // 歌词时间(秒) 110 | val lyric: String, 111 | var translation: String? 112 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # RainMusic 4 | 5 | > ⚠️ 这是一个玩具项目,早起学compose的时候写着玩的,已经放弃继续开发了 6 | 7 | [![GitHub issues](https://img.shields.io/github/issues/re-ovo/RainMusic)](https://github.com/re-ovo/RainMusic/issues) 8 | [![GitHub forks](https://img.shields.io/github/forks/re-ovo/RainMusic)](https://github.com/re-ovo/RainMusic/network) 9 | [![GitHub stars](https://img.shields.io/github/stars/re-ovo/RainMusic)](https://github.com/re-ovo/RainMusic/stargazers) 10 | [![GitHub license](https://img.shields.io/github/license/re-ovo/RainMusic)](https://github.com/re-ovo/RainMusic/blob/master/LICENSE) 11 | 12 | RainMusic是一个使用 [Jetpack Compose](https://developer.android.com/jetpack/compose) 构建的网易云第三方app, 13 | 采用 [Material You](https://m3.material.io/) 设计,专注听歌功能,没有社交功能,还你一个纯净的音乐APP。 14 | 15 | # 当前主要计划 16 | 17 | - [ ] 重写架构,改为官方最新推荐的架构 (data/domain/ui) 18 | - [ ] 重写MusicService, 更好的支持音乐切换,而不是一次性加载所有歌曲 19 | - [ ] 重写播放器UI,规范代码 20 | 21 | ## 👀 注意事项 22 | 23 | 1. 本APP完全免费,请勿用于商业用途或非法用途,仅供个人学习使用,任何修改版本导致的问题与本人无关 24 | 2. 请勿提交任何"破解VIP", "破解灰色歌单" 之类的侵权功能请求或者PR, 此类请求会被直接close 25 | 3. 本APP不会实现`黑胶充值功能,注册功能` 等类似敏感功能,请自行使用官方APP完成这类操作 26 | 4. 请勿公开传播该APP,喜欢的个人使用就好! 27 | 28 | ## 🎯 特性 29 | 30 | * Material You 设计 31 | * 推荐页面 32 | * 日推 33 | * 歌单 34 | * 歌词 35 | * 自动签到 36 | * 一言 37 | * 私人FM (WIP) 38 | * 搜索 (WIP) 39 | 40 | ## 📖 TODO List 41 | * 改进播放器UI,支持添加歌曲到歌单 42 | * 添加私人FM支持 43 | * 添加侧拉栏 44 | 45 | ## 🖼️ 截图展示 46 | | 推荐 | 发现 | 47 | | ----- | ------| 48 | | | | 49 | | 播放器 | 歌词 | 50 | | | | 51 | ## 📦️ 下载安装包 52 | * 开发中,暂时不提供下载,感兴趣的可以自行编译试用 53 | 54 | ## 📭 常见问题 55 | 1. **有没有iOS版?** 56 | 答: 当然没有,用iOS就和小众app说再见吧 57 | 2. **使用这个app有账号安全隐患吗?** 58 | 答: 本app之和网易官方API通信,欢迎检查代码,同时请在这里下载app,请不要下载来路不明的版本 59 | 3. **能否添加评论功能?** 60 | 答: 不会添加 61 | 62 | ## 🎲 技术栈 63 | * Jetpack Compose 64 | * Kotlin Flow驱动,无LiveData 65 | * MVVM架构 66 | * Navigation + 单Activity 67 | * Room 68 | * Retrofit 69 | * Hilt 70 | * Androidx Media3 71 | 72 | ~~快毕业的无业游民, 有无大佬内推~~😅 73 | 74 | ## 🤩 感谢 75 | * 感谢 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 项目,本APP的API调用部分大量参考了该项目的代码 76 | * 感谢 [music-java-api](https://github.com/jnwang95/music-java-api) 项目的加密Java实现 77 | * 感谢 [hitokoto.cn](https://hitokoto.cn) 的一言API 78 | 79 | ## 🔭 参与到本项目 80 | 如果你懂Jetpack Compose和Kotlin,欢迎提交PR! 81 | 82 | ## 📡 开源协议 83 | ```text 84 | MIT License 85 | 86 | Copyright (c) 2021 RERERE 87 | 88 | Permission is hereby granted, free of charge, to any person obtaining a copy 89 | of this software and associated documentation files (the "Software"), to deal 90 | in the Software without restriction, including without limitation the rights 91 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 92 | copies of the Software, and to permit persons to whom the Software is 93 | furnished to do so, subject to the following conditions: 94 | 95 | The above copyright notice and this permission notice shall be included in all 96 | copies or substantial portions of the Software. 97 | 98 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 99 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 100 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 101 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 102 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 103 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 104 | SOFTWARE. 105 | ``` 106 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/component/AppBar.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.component 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.rounded.ArrowBack 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.CompositionLocalProvider 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.graphics.vector.ImageVector 12 | import androidx.compose.ui.unit.dp 13 | import me.rerere.rainmusic.ui.local.LocalNavController 14 | 15 | /** 16 | * 支持Edge to Edge的TopBar 17 | */ 18 | @Composable 19 | fun RainTopBar( 20 | title: @Composable () -> Unit, 21 | modifier: Modifier = Modifier, 22 | contentPadding: PaddingValues = WindowInsets.statusBars.asPaddingValues(), 23 | navigationIcon: @Composable () -> Unit = {}, 24 | actions: @Composable RowScope.() -> Unit = {}, 25 | colors: TopAppBarColors = TopAppBarDefaults.smallTopAppBarColors(), 26 | appBarStyle: AppBarStyle = AppBarStyle.Small, 27 | scrollBehavior: TopAppBarScrollBehavior? = null 28 | ){ 29 | val scrollFraction = scrollBehavior?.scrollFraction ?: 0f 30 | val appBarContainerColor by colors.containerColor(scrollFraction) 31 | 32 | Surface(modifier = modifier, color = appBarContainerColor) { 33 | when(appBarStyle){ 34 | AppBarStyle.Small -> { 35 | SmallTopAppBar( 36 | modifier = Modifier.padding(contentPadding), 37 | title = title, 38 | navigationIcon = navigationIcon, 39 | actions = actions, 40 | colors = colors, 41 | scrollBehavior = scrollBehavior 42 | ) 43 | } 44 | AppBarStyle.Medium -> { 45 | MediumTopAppBar( 46 | modifier = Modifier.padding(contentPadding), 47 | title = title, 48 | navigationIcon = navigationIcon, 49 | actions = actions, 50 | colors = colors, 51 | scrollBehavior = scrollBehavior 52 | ) 53 | } 54 | AppBarStyle.Large -> { 55 | LargeTopAppBar( 56 | modifier = Modifier.padding(contentPadding), 57 | title = title, 58 | navigationIcon = navigationIcon, 59 | actions = actions, 60 | colors = colors, 61 | scrollBehavior = scrollBehavior 62 | ) 63 | } 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * 支持Edge to Edge的 NavigationBar 70 | */ 71 | @Composable 72 | fun RainBottomNavigation( 73 | content: @Composable RowScope.() -> Unit 74 | ) { 75 | Surface( 76 | tonalElevation = 3.dp 77 | ) { 78 | CompositionLocalProvider( 79 | LocalAbsoluteTonalElevation provides LocalAbsoluteTonalElevation.current - 3.dp 80 | ) { 81 | NavigationBar( 82 | modifier = Modifier.padding( 83 | WindowInsets.navigationBars.asPaddingValues() 84 | ) 85 | ) { 86 | content() 87 | } 88 | } 89 | } 90 | } 91 | 92 | /** 93 | * 点击返回上一级的按钮 94 | */ 95 | @Composable 96 | fun PopBackIcon( 97 | icon: ImageVector = Icons.Rounded.ArrowBack 98 | ){ 99 | val navController = LocalNavController.current 100 | IconButton(onClick = { 101 | navController.popBackStack() 102 | }) { 103 | Icon(icon, "Back") 104 | } 105 | } 106 | 107 | /** 108 | * 顶栏样式 109 | */ 110 | enum class AppBarStyle { 111 | Small, 112 | Medium, 113 | Large 114 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/repo/UserRepo.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.repo 2 | 3 | import com.soywiz.krypto.md5 4 | import kotlinx.coroutines.flow.flow 5 | import me.rerere.rainmusic.data.retrofit.api.NeteaseMusicApi 6 | import me.rerere.rainmusic.data.retrofit.weapi.NeteaseMusicWeApi 7 | import me.rerere.rainmusic.util.DataState 8 | import me.rerere.rainmusic.util.encrypt.encryptWeAPI 9 | import javax.inject.Inject 10 | 11 | private const val TAG = "UserRepo" 12 | 13 | class UserRepo @Inject constructor( 14 | private val api: NeteaseMusicApi, 15 | private val weApi: NeteaseMusicWeApi 16 | ) { 17 | fun refreshLogin() = flow { 18 | emit(DataState.Loading) 19 | try { 20 | val result = weApi.refreshLogin() 21 | require(result.get("code").asInt != 301) 22 | emit(DataState.Success(Unit)) 23 | } catch (e: java.lang.Exception) { 24 | e.printStackTrace() 25 | emit(DataState.Error(e)) 26 | } 27 | } 28 | 29 | fun loginCellPhone( 30 | phone: String, 31 | password: String 32 | ) = flow { 33 | emit(DataState.Loading) 34 | kotlinx.coroutines.delay(500) // 等待1秒,防止登录对话框来不及显示 35 | try { 36 | val result = weApi.loginCellphone( 37 | encryptWeAPI( 38 | mapOf( 39 | "phone" to phone, 40 | "countrycode" to "86", 41 | "password" to password.toByteArray().md5().hex, 42 | "rememberLogin" to "true" 43 | ) 44 | ) 45 | ) 46 | emit(DataState.Success(result)) 47 | } catch (e: Exception) { 48 | e.printStackTrace() 49 | emit(DataState.Error(e)) 50 | } 51 | } 52 | 53 | fun getAccountDetail() = flow { 54 | emit(DataState.Loading) 55 | try { 56 | val result = api.getAccountDetail() 57 | 58 | require(result.profile != null) 59 | require(result.account != null) 60 | 61 | emit(DataState.Success(result)) 62 | } catch (e: Exception) { 63 | e.printStackTrace() 64 | emit(DataState.Error(e)) 65 | } 66 | } 67 | 68 | fun getLikeList(uid: Long) = flow { 69 | emit(DataState.Loading) 70 | try { 71 | val result = weApi.getLikeList( 72 | encryptWeAPI( 73 | mapOf( 74 | "uid" to uid.toString() 75 | ) 76 | ) 77 | ) 78 | emit(DataState.Success(result)) 79 | } catch (e: Exception) { 80 | e.printStackTrace() 81 | emit(DataState.Error(e)) 82 | } 83 | } 84 | 85 | fun getUserPlaylists( 86 | uid: Long, 87 | limit: Int = 10 88 | ) = flow { 89 | emit(DataState.Loading) 90 | try { 91 | val result = api.getUserPlaylist( 92 | mapOf( 93 | "uid" to uid.toString(), 94 | "limit" to limit.toString(), 95 | "includeVideo" to "false" 96 | ) 97 | ) 98 | emit(DataState.Success(result)) 99 | } catch (e: Exception) { 100 | e.printStackTrace() 101 | emit(DataState.Error(e)) 102 | } 103 | } 104 | 105 | fun dailySign() = flow { 106 | emit(DataState.Loading) 107 | try { 108 | val result = weApi.dailySign( 109 | encryptWeAPI( 110 | mapOf( 111 | "type" to "0" // Android 签到 112 | ) 113 | ) 114 | ) 115 | emit(DataState.Success(result)) 116 | } catch (e: Exception) { 117 | e.printStackTrace() 118 | emit(DataState.Error(e)) 119 | } 120 | } 121 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/Toplists.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class Toplists( 7 | @SerializedName("artistToplist") 8 | val artistToplist: ArtistToplist, 9 | @SerializedName("code") 10 | val code: Int, 11 | @SerializedName("list") 12 | val list: List 13 | ) { 14 | data class ArtistToplist( 15 | @SerializedName("coverUrl") 16 | val coverUrl: String, 17 | @SerializedName("name") 18 | val name: String, 19 | @SerializedName("position") 20 | val position: Int, 21 | @SerializedName("upateFrequency") 22 | val upateFrequency: String, 23 | @SerializedName("updateFrequency") 24 | val updateFrequency: String 25 | ) 26 | 27 | data class TopList( 28 | @SerializedName("adType") 29 | val adType: Int, 30 | @SerializedName("anonimous") 31 | val anonimous: Boolean, 32 | @SerializedName("artists") 33 | val artists: Any, 34 | @SerializedName("backgroundCoverId") 35 | val backgroundCoverId: Int, 36 | @SerializedName("backgroundCoverUrl") 37 | val backgroundCoverUrl: Any, 38 | @SerializedName("cloudTrackCount") 39 | val cloudTrackCount: Int, 40 | @SerializedName("commentThreadId") 41 | val commentThreadId: String, 42 | @SerializedName("coverImgId") 43 | val coverImgId: Long, 44 | @SerializedName("coverImgId_str") 45 | val coverImgIdStr: String, 46 | @SerializedName("coverImgUrl") 47 | val coverImgUrl: String, 48 | @SerializedName("createTime") 49 | val createTime: Long, 50 | @SerializedName("creator") 51 | val creator: Any, 52 | @SerializedName("description") 53 | val description: String, 54 | @SerializedName("englishTitle") 55 | val englishTitle: Any, 56 | @SerializedName("highQuality") 57 | val highQuality: Boolean, 58 | @SerializedName("id") 59 | val id: Long, 60 | @SerializedName("name") 61 | val name: String, 62 | @SerializedName("newImported") 63 | val newImported: Boolean, 64 | @SerializedName("opRecommend") 65 | val opRecommend: Boolean, 66 | @SerializedName("ordered") 67 | val ordered: Boolean, 68 | @SerializedName("playCount") 69 | val playCount: Long, 70 | @SerializedName("privacy") 71 | val privacy: Int, 72 | @SerializedName("recommendInfo") 73 | val recommendInfo: Any, 74 | @SerializedName("specialType") 75 | val specialType: Int, 76 | @SerializedName("status") 77 | val status: Int, 78 | @SerializedName("subscribed") 79 | val subscribed: Any, 80 | @SerializedName("subscribedCount") 81 | val subscribedCount: Int, 82 | @SerializedName("subscribers") 83 | val subscribers: List, 84 | @SerializedName("tags") 85 | val tags: List, 86 | @SerializedName("titleImage") 87 | val titleImage: Int, 88 | @SerializedName("titleImageUrl") 89 | val titleImageUrl: Any, 90 | @SerializedName("ToplistType") 91 | val toplistType: String, 92 | @SerializedName("totalDuration") 93 | val totalDuration: Int, 94 | @SerializedName("trackCount") 95 | val trackCount: Int, 96 | @SerializedName("trackNumberUpdateTime") 97 | val trackNumberUpdateTime: Long, 98 | @SerializedName("trackUpdateTime") 99 | val trackUpdateTime: Long, 100 | @SerializedName("tracks") 101 | val tracks: Any, 102 | @SerializedName("updateFrequency") 103 | val updateFrequency: String, 104 | @SerializedName("updateTime") 105 | val updateTime: Long, 106 | @SerializedName("userId") 107 | val userId: Long 108 | ) 109 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/weapi/model/LoginResponse.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.weapi.model 2 | import com.google.gson.annotations.SerializedName 3 | 4 | 5 | data class LoginResponse( 6 | @SerializedName("account") 7 | val account: Account, 8 | @SerializedName("code") 9 | val code: Int, 10 | @SerializedName("loginType") 11 | val loginType: Int, 12 | @SerializedName("profile") 13 | val profile: Profile, 14 | @SerializedName("token") 15 | val token: String 16 | ) { 17 | data class Account( 18 | @SerializedName("anonimousUser") 19 | val anonimousUser: Boolean, 20 | @SerializedName("ban") 21 | val ban: Int, 22 | @SerializedName("baoyueVersion") 23 | val baoyueVersion: Int, 24 | @SerializedName("createTime") 25 | val createTime: Long, 26 | @SerializedName("donateVersion") 27 | val donateVersion: Int, 28 | @SerializedName("id") 29 | val id: Long, 30 | @SerializedName("salt") 31 | val salt: String, 32 | @SerializedName("status") 33 | val status: Int, 34 | @SerializedName("tokenVersion") 35 | val tokenVersion: Int, 36 | @SerializedName("type") 37 | val type: Int, 38 | @SerializedName("userName") 39 | val userName: String, 40 | @SerializedName("vipType") 41 | val vipType: Int, 42 | @SerializedName("viptypeVersion") 43 | val viptypeVersion: Long, 44 | @SerializedName("whitelistAuthority") 45 | val whitelistAuthority: Int 46 | ) 47 | 48 | data class Profile( 49 | @SerializedName("accountStatus") 50 | val accountStatus: Int, 51 | @SerializedName("authStatus") 52 | val authStatus: Int, 53 | @SerializedName("authority") 54 | val authority: Int, 55 | @SerializedName("avatarDetail") 56 | val avatarDetail: Any, 57 | @SerializedName("avatarImgId") 58 | val avatarImgId: Long, 59 | @SerializedName("avatarImgIdStr") 60 | val avatarImgIdStr: String, 61 | @SerializedName("avatarUrl") 62 | val avatarUrl: String, 63 | @SerializedName("backgroundImgId") 64 | val backgroundImgId: Long, 65 | @SerializedName("backgroundImgIdStr") 66 | val backgroundImgIdStr: String, 67 | @SerializedName("backgroundUrl") 68 | val backgroundUrl: String, 69 | @SerializedName("birthday") 70 | val birthday: Long, 71 | @SerializedName("city") 72 | val city: Int, 73 | @SerializedName("defaultAvatar") 74 | val defaultAvatar: Boolean, 75 | @SerializedName("description") 76 | val description: String, 77 | @SerializedName("detailDescription") 78 | val detailDescription: String, 79 | @SerializedName("djStatus") 80 | val djStatus: Int, 81 | @SerializedName("eventCount") 82 | val eventCount: Int, 83 | @SerializedName("expertTags") 84 | val expertTags: Any, 85 | @SerializedName("experts") 86 | val experts: Experts, 87 | @SerializedName("followed") 88 | val followed: Boolean, 89 | @SerializedName("followeds") 90 | val followeds: Int, 91 | @SerializedName("follows") 92 | val follows: Int, 93 | @SerializedName("gender") 94 | val gender: Int, 95 | @SerializedName("mutual") 96 | val mutual: Boolean, 97 | @SerializedName("nickname") 98 | val nickname: String, 99 | @SerializedName("playlistBeSubscribedCount") 100 | val playlistBeSubscribedCount: Int, 101 | @SerializedName("playlistCount") 102 | val playlistCount: Int, 103 | @SerializedName("province") 104 | val province: Int, 105 | @SerializedName("remarkName") 106 | val remarkName: Any, 107 | @SerializedName("signature") 108 | val signature: String, 109 | @SerializedName("userId") 110 | val userId: Int, 111 | @SerializedName("userType") 112 | val userType: Int, 113 | @SerializedName("vipType") 114 | val vipType: Int 115 | ) { 116 | class Experts 117 | } 118 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/AccountDetail.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class AccountDetail( 7 | @SerializedName("account") 8 | val account: Account?, 9 | @SerializedName("code") 10 | val code: Int, 11 | @SerializedName("profile") 12 | val profile: Profile? 13 | ) { 14 | data class Account( 15 | @SerializedName("anonimousUser") 16 | val anonimousUser: Boolean, 17 | @SerializedName("ban") 18 | val ban: Int, 19 | @SerializedName("baoyueVersion") 20 | val baoyueVersion: Int, 21 | @SerializedName("createTime") 22 | val createTime: Long, 23 | @SerializedName("donateVersion") 24 | val donateVersion: Int, 25 | @SerializedName("id") 26 | val id: Long, 27 | @SerializedName("paidFee") 28 | val paidFee: Boolean, 29 | @SerializedName("status") 30 | val status: Int, 31 | @SerializedName("tokenVersion") 32 | val tokenVersion: Int, 33 | @SerializedName("type") 34 | val type: Int, 35 | @SerializedName("userName") 36 | val userName: String, 37 | @SerializedName("vipType") 38 | val vipType: Int, 39 | @SerializedName("whitelistAuthority") 40 | val whitelistAuthority: Int 41 | ) 42 | 43 | data class Profile( 44 | @SerializedName("accountStatus") 45 | val accountStatus: Int, 46 | @SerializedName("accountType") 47 | val accountType: Int, 48 | @SerializedName("anchor") 49 | val anchor: Boolean, 50 | @SerializedName("authStatus") 51 | val authStatus: Int, 52 | @SerializedName("authenticated") 53 | val authenticated: Boolean, 54 | @SerializedName("authenticationTypes") 55 | val authenticationTypes: Int, 56 | @SerializedName("authority") 57 | val authority: Int, 58 | @SerializedName("avatarDetail") 59 | val avatarDetail: Any, 60 | @SerializedName("avatarImgId") 61 | val avatarImgId: Long, 62 | @SerializedName("avatarUrl") 63 | val avatarUrl: String, 64 | @SerializedName("backgroundImgId") 65 | val backgroundImgId: Long, 66 | @SerializedName("backgroundUrl") 67 | val backgroundUrl: String, 68 | @SerializedName("birthday") 69 | val birthday: Long, 70 | @SerializedName("city") 71 | val city: Int, 72 | @SerializedName("createTime") 73 | val createTime: Long, 74 | @SerializedName("defaultAvatar") 75 | val defaultAvatar: Boolean, 76 | @SerializedName("description") 77 | val description: Any, 78 | @SerializedName("detailDescription") 79 | val detailDescription: Any, 80 | @SerializedName("djStatus") 81 | val djStatus: Int, 82 | @SerializedName("expertTags") 83 | val expertTags: Any, 84 | @SerializedName("experts") 85 | val experts: Any, 86 | @SerializedName("followed") 87 | val followed: Boolean, 88 | @SerializedName("gender") 89 | val gender: Int, 90 | @SerializedName("lastLoginIP") 91 | val lastLoginIP: String, 92 | @SerializedName("lastLoginTime") 93 | val lastLoginTime: Long, 94 | @SerializedName("locationStatus") 95 | val locationStatus: Int, 96 | @SerializedName("mutual") 97 | val mutual: Boolean, 98 | @SerializedName("nickname") 99 | val nickname: String, 100 | @SerializedName("province") 101 | val province: Int, 102 | @SerializedName("remarkName") 103 | val remarkName: Any, 104 | @SerializedName("shortUserName") 105 | val shortUserName: String, 106 | @SerializedName("signature") 107 | val signature: String, 108 | @SerializedName("userId") 109 | val userId: Long, 110 | @SerializedName("userName") 111 | val userName: String, 112 | @SerializedName("userType") 113 | val userType: Int, 114 | @SerializedName("vipType") 115 | val vipType: Int, 116 | @SerializedName("viptypeVersion") 117 | val viptypeVersion: Long 118 | ) 119 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/index/IndexViewModel.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.index 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.PagingData 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.launchIn 10 | import kotlinx.coroutines.flow.onEach 11 | import kotlinx.coroutines.launch 12 | import me.rerere.rainmusic.data.model.Playlists 13 | import me.rerere.rainmusic.data.retrofit.api.model.HighQualityPlaylist 14 | import me.rerere.rainmusic.data.retrofit.api.model.Toplists 15 | import me.rerere.rainmusic.data.retrofit.api.model.UserPlaylists 16 | import me.rerere.rainmusic.data.retrofit.weapi.model.NewSongs 17 | import me.rerere.rainmusic.data.retrofit.weapi.model.PersonalizedPlaylist 18 | import me.rerere.rainmusic.data.retrofit.weapi.model.PlaylistCategory 19 | import me.rerere.rainmusic.repo.MusicRepo 20 | import me.rerere.rainmusic.repo.UserRepo 21 | import me.rerere.rainmusic.repo.YiYan 22 | import me.rerere.rainmusic.repo.YiYanRepo 23 | import me.rerere.rainmusic.util.DataState 24 | import me.rerere.rainmusic.util.sharedPreferencesOf 25 | import javax.inject.Inject 26 | 27 | @HiltViewModel 28 | class IndexViewModel @Inject constructor( 29 | private val userRepo: UserRepo, 30 | val musicRepo: MusicRepo, 31 | private val yiYanRepo: YiYanRepo 32 | ): ViewModel() { 33 | // recommend page 34 | val personalizedPlaylist: MutableStateFlow> = MutableStateFlow(DataState.Empty) 35 | val personalizedSongs: MutableStateFlow> = MutableStateFlow(DataState.Empty) 36 | val toplist: MutableStateFlow> = MutableStateFlow(DataState.Empty) 37 | val yiyan: MutableStateFlow> = MutableStateFlow(DataState.Empty) 38 | 39 | // playlist discover 40 | val categoryAll: MutableStateFlow> = MutableStateFlow(DataState.Empty) 41 | val categorySelected: MutableStateFlow> = MutableStateFlow(emptyList()) 42 | val highQualityPlaylist: MutableStateFlow> = MutableStateFlow(DataState.Empty) 43 | val playlistCatPager: MutableMap>> = mutableMapOf() 44 | 45 | // library page 46 | val userPlaylist: MutableStateFlow> = MutableStateFlow(DataState.Empty) 47 | 48 | fun refreshIndexPage(){ 49 | refreshHotComment() 50 | 51 | musicRepo.getPersonalizedPlaylist(10) 52 | .onEach { 53 | personalizedPlaylist.value = it 54 | } 55 | .launchIn(viewModelScope) 56 | musicRepo.getNewSongs() 57 | .onEach { 58 | personalizedSongs.value = it 59 | } 60 | .launchIn(viewModelScope) 61 | musicRepo.getTopList() 62 | .onEach { 63 | toplist.value = it 64 | }.launchIn(viewModelScope) 65 | } 66 | 67 | fun refreshExplorePage() { 68 | musicRepo.getPlaylistCategory().onEach { 69 | categoryAll.value = it 70 | }.launchIn(viewModelScope) 71 | 72 | musicRepo.getHighQualityPlaylist( 73 | cat = "全部", 74 | limit = 50 75 | ).onEach { 76 | highQualityPlaylist.value = it 77 | }.launchIn(viewModelScope) 78 | 79 | refreshSelectedCategory() 80 | } 81 | 82 | fun refreshSelectedCategory(){ 83 | viewModelScope.launch { 84 | val sharedPreferences = sharedPreferencesOf("playlist_category") 85 | categorySelected.value = if (sharedPreferences.contains("data")) { 86 | // 载入用户自定义歌单category 87 | (sharedPreferences.getString("data", "")?.split(",") ?: emptyList()) 88 | } else { 89 | // 载入热门category 90 | musicRepo.getHotPlaylistTags()?.tags?.map { it.name } ?: emptyList() 91 | } 92 | } 93 | } 94 | 95 | fun refreshHotComment() { 96 | yiYanRepo.loadYiYan() 97 | .onEach { 98 | yiyan.value = it 99 | }.launchIn(viewModelScope) 100 | } 101 | 102 | fun refreshLibraryPage(id: Long) { 103 | userRepo.getUserPlaylists( 104 | uid = id, 105 | limit = 1000 106 | ).onEach { 107 | userPlaylist.value = it 108 | }.launchIn(viewModelScope) 109 | } 110 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'dagger.hilt.android.plugin' 5 | id 'kotlin-kapt' 6 | } 7 | 8 | android { 9 | compileSdk 31 10 | 11 | defaultConfig { 12 | applicationId "me.rerere.rainmusic" 13 | minSdk 24 14 | targetSdk 31 15 | versionCode 1 16 | versionName "1.0.0" 17 | 18 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { 20 | useSupportLibrary true 21 | } 22 | } 23 | 24 | buildTypes { 25 | release { 26 | minifyEnabled true 27 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 28 | } 29 | } 30 | compileOptions { 31 | sourceCompatibility JavaVersion.VERSION_1_8 32 | targetCompatibility JavaVersion.VERSION_1_8 33 | } 34 | kotlinOptions { 35 | jvmTarget = '1.8' 36 | } 37 | buildFeatures { 38 | compose true 39 | viewBinding true 40 | } 41 | composeOptions { 42 | kotlinCompilerExtensionVersion compose_version 43 | } 44 | packagingOptions { 45 | resources { 46 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 47 | } 48 | } 49 | } 50 | 51 | dependencies { 52 | implementation 'androidx.core:core-ktx:1.7.0' 53 | implementation 'androidx.activity:activity-compose:1.4.0' 54 | 55 | // media3 56 | implementation "androidx.media3:media3-session:$media3_version" 57 | implementation "androidx.media3:media3-common:$media3_version" 58 | implementation "androidx.media3:media3-exoplayer:$media3_version" 59 | 60 | // splash screen 61 | implementation "androidx.core:core-splashscreen:1.0.0-beta02" 62 | 63 | // 持久化State 64 | implementation 'dev.burnoo:compose-remember-preference:0.3.4' 65 | 66 | // compose library 67 | implementation "androidx.compose.ui:ui:$compose_version" 68 | implementation "androidx.compose.material:material:$compose_version" 69 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 70 | implementation "androidx.compose.material:material-icons-extended:$compose_version" 71 | implementation "androidx.compose.material3:material3:1.0.0-alpha09" 72 | 73 | // hilt 74 | implementation "com.google.dagger:hilt-android:$hilt_version" 75 | kapt "com.google.dagger:hilt-compiler:$hilt_version" 76 | implementation 'androidx.hilt:hilt-navigation-compose:1.0.0' 77 | 78 | // Paging 3 79 | implementation "androidx.paging:paging-runtime:$paging_version" 80 | implementation "androidx.paging:paging-runtime-ktx:$paging_version" 81 | implementation "androidx.paging:paging-compose:1.0.0-alpha14" 82 | 83 | // Navigation for JetpackCompose 84 | implementation "androidx.navigation:navigation-compose:2.5.0-beta01" 85 | 86 | // Coil 87 | implementation("io.coil-kt:coil-compose:1.4.0") 88 | 89 | // Material Motion 动画 90 | implementation "com.github.fornewid.material-motion-compose:core:0.9.0-alpha04" 91 | 92 | // accompanist 93 | implementation "com.google.accompanist:accompanist-pager:$accompanist_version" 94 | implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" 95 | implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_version" 96 | implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" 97 | implementation "com.google.accompanist:accompanist-placeholder-material:$accompanist_version" 98 | implementation "com.google.accompanist:accompanist-navigation-animation:$accompanist_version" 99 | implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" 100 | 101 | // OkHttp & Retrofit 102 | implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.7' 103 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 104 | implementation 'com.squareup.retrofit2:converter-gson:2.9.0' 105 | implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.7' 106 | 107 | // 加密库 108 | def kryptoVersion = "2.4.8" 109 | implementation("com.soywiz.korlibs.krypto:krypto-android:$kryptoVersion") 110 | 111 | // test 112 | implementation "androidx.test.ext:junit:1.1.3" 113 | implementation "androidx.test.ext:junit-ktx:1.1.3" 114 | implementation "androidx.compose.ui:ui-test-junit4:1.1.1" 115 | } 116 | 117 | kapt { 118 | correctErrorTypes true 119 | } 120 | 121 | // 禁用掉烦人的警告 122 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { 123 | kotlinOptions { 124 | freeCompilerArgs += "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api" 125 | freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi" 126 | freeCompilerArgs += "-opt-in=androidx.compose.animation.ExperimentalAnimationApi" 127 | freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" 128 | freeCompilerArgs += "-opt-in=com.google.accompanist.pager.ExperimentalPagerApi" 129 | freeCompilerArgs += "-opt-in=coil.annotation.ExperimentalCoilApi" 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/service/MusicService.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.service 2 | 3 | import android.app.PendingIntent 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.util.Log 7 | import androidx.media3.common.AudioAttributes 8 | import androidx.media3.common.C 9 | import androidx.media3.common.MediaItem 10 | import androidx.media3.common.Player 11 | import androidx.media3.datasource.DataSpec 12 | import androidx.media3.datasource.DefaultDataSource 13 | import androidx.media3.datasource.ResolvingDataSource 14 | import androidx.media3.exoplayer.ExoPlayer 15 | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory 16 | import androidx.media3.extractor.DefaultExtractorsFactory 17 | import androidx.media3.session.MediaLibraryService 18 | import androidx.media3.session.MediaSession 19 | import dagger.hilt.android.AndroidEntryPoint 20 | import kotlinx.coroutines.* 21 | import me.rerere.rainmusic.RouteActivity 22 | import me.rerere.rainmusic.repo.MusicRepo 23 | import me.rerere.rainmusic.util.DataState 24 | import me.rerere.rainmusic.util.RainMusicProtocol 25 | import me.rerere.rainmusic.util.okhttp.https 26 | import java.io.IOException 27 | import javax.inject.Inject 28 | 29 | private const val TAG = "MusicService" 30 | 31 | @AndroidEntryPoint 32 | class MusicService : MediaLibraryService() { 33 | @Inject 34 | lateinit var musicRepo: MusicRepo 35 | private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) 36 | 37 | private lateinit var player: Player 38 | private lateinit var mediaSession: MediaLibrarySession 39 | 40 | override fun onCreate() { 41 | super.onCreate() 42 | 43 | player = ExoPlayer.Builder(this) 44 | .setAudioAttributes(AudioAttributes.DEFAULT, true) 45 | .setHandleAudioBecomingNoisy(true) 46 | .setMediaSourceFactory( 47 | DefaultMediaSourceFactory( 48 | // 自定义datasource 49 | ResolvingDataSource.Factory(DefaultDataSource.Factory(this), NeteaseMusicResolver()), 50 | DefaultExtractorsFactory() 51 | ), 52 | ) 53 | .setWakeMode(C.WAKE_MODE_LOCAL) 54 | .build() 55 | 56 | player.repeatMode = Player.REPEAT_MODE_ALL 57 | 58 | mediaSession = MediaLibrarySession.Builder(this, player, LibrarySessionCallback()) 59 | .setMediaItemFiller(CustomMediaItemFiller()) 60 | .setSessionActivity( 61 | PendingIntent.getActivity( 62 | this, 63 | 0, 64 | Intent(this, RouteActivity::class.java), 65 | PendingIntent.FLAG_IMMUTABLE 66 | ) 67 | ) 68 | .build() 69 | } 70 | 71 | override fun onDestroy() { 72 | lifecycleScope.cancel() 73 | 74 | player.release() 75 | mediaSession.release() 76 | 77 | super.onDestroy() 78 | } 79 | 80 | override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession { 81 | return mediaSession 82 | } 83 | 84 | inner class CustomMediaItemFiller : MediaSession.MediaItemFiller { 85 | override fun fillInLocalConfiguration( 86 | session: MediaSession, 87 | controller: MediaSession.ControllerInfo, 88 | mediaItem: MediaItem 89 | ): MediaItem { 90 | return mediaItem.buildUpon() 91 | .setUri(mediaItem.mediaMetadata.mediaUri) 92 | .build() 93 | } 94 | } 95 | 96 | class LibrarySessionCallback : MediaLibrarySession.MediaLibrarySessionCallback { 97 | 98 | } 99 | 100 | inner class NeteaseMusicResolver : ResolvingDataSource.Resolver { 101 | override fun resolveDataSpec(dataSpec: DataSpec): DataSpec { 102 | // 动态解析歌曲地址 103 | if(dataSpec.uri.scheme == RainMusicProtocol && dataSpec.uri.host == "music"){ 104 | val musicId = dataSpec.uri.getQueryParameter("id")?.toLong() ?: error("can't find music id") 105 | Log.i(TAG, "resolveDataSpec: 开始解析歌曲($musicId)的播放地址") 106 | val url = runBlocking { 107 | var musicUrl = "" 108 | musicRepo.getMusicUrl(musicId).collect { 109 | if(it is DataState.Success && musicUrl.isBlank()){ 110 | musicUrl = it.readSafely()?.data?.get(0)?.url ?: "" 111 | } 112 | } 113 | musicUrl 114 | } 115 | Log.i(TAG, "resolveDataSpec: 解析完成: $url") 116 | if(url.isBlank()){ 117 | lifecycleScope.launch { 118 | if (player.hasNextMediaItem()) { 119 | player.seekToNextMediaItem() 120 | player.prepare() 121 | player.play() 122 | } else { 123 | throw IOException("无法解析Music URL") 124 | } 125 | } 126 | } 127 | return dataSpec.buildUpon() 128 | .apply { 129 | if(url.isNotBlank()){ 130 | setUri(Uri.parse(url.https)) 131 | } 132 | } 133 | .build() 134 | } 135 | return dataSpec 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/UserPlaylists.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class UserPlaylists( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("more") 10 | val more: Boolean, 11 | @SerializedName("playlist") 12 | val playlist: List, 13 | @SerializedName("version") 14 | val version: String 15 | ) { 16 | data class Playlist( 17 | @SerializedName("adType") 18 | val adType: Int, 19 | @SerializedName("anonimous") 20 | val anonimous: Boolean, 21 | @SerializedName("artists") 22 | val artists: Any, 23 | @SerializedName("backgroundCoverId") 24 | val backgroundCoverId: Long, 25 | @SerializedName("backgroundCoverUrl") 26 | val backgroundCoverUrl: Any, 27 | @SerializedName("cloudTrackCount") 28 | val cloudTrackCount: Int, 29 | @SerializedName("commentThreadId") 30 | val commentThreadId: String, 31 | @SerializedName("coverImgId") 32 | val coverImgId: Long, 33 | @SerializedName("coverImgId_str") 34 | val coverImgIdStr: String, 35 | @SerializedName("coverImgUrl") 36 | val coverImgUrl: String, 37 | @SerializedName("createTime") 38 | val createTime: Long, 39 | @SerializedName("creator") 40 | val creator: Creator, 41 | @SerializedName("description") 42 | val description: Any, 43 | @SerializedName("englishTitle") 44 | val englishTitle: Any, 45 | @SerializedName("highQuality") 46 | val highQuality: Boolean, 47 | @SerializedName("id") 48 | val id: Long, 49 | @SerializedName("name") 50 | val name: String, 51 | @SerializedName("newImported") 52 | val newImported: Boolean, 53 | @SerializedName("opRecommend") 54 | val opRecommend: Boolean, 55 | @SerializedName("ordered") 56 | val ordered: Boolean, 57 | @SerializedName("playCount") 58 | val playCount: Long, 59 | @SerializedName("privacy") 60 | val privacy: Int, 61 | @SerializedName("recommendInfo") 62 | val recommendInfo: Any, 63 | @SerializedName("shareStatus") 64 | val shareStatus: Any, 65 | @SerializedName("sharedUsers") 66 | val sharedUsers: Any, 67 | @SerializedName("specialType") 68 | val specialType: Int, 69 | @SerializedName("status") 70 | val status: Int, 71 | @SerializedName("subscribed") 72 | val subscribed: Boolean, 73 | @SerializedName("subscribedCount") 74 | val subscribedCount: Int, 75 | @SerializedName("subscribers") 76 | val subscribers: List, 77 | @SerializedName("tags") 78 | val tags: List, 79 | @SerializedName("titleImage") 80 | val titleImage: Long, 81 | @SerializedName("titleImageUrl") 82 | val titleImageUrl: Any, 83 | @SerializedName("totalDuration") 84 | val totalDuration: Int, 85 | @SerializedName("trackCount") 86 | val trackCount: Int, 87 | @SerializedName("trackNumberUpdateTime") 88 | val trackNumberUpdateTime: Long, 89 | @SerializedName("trackUpdateTime") 90 | val trackUpdateTime: Long, 91 | @SerializedName("tracks") 92 | val tracks: Any, 93 | @SerializedName("updateFrequency") 94 | val updateFrequency: Any, 95 | @SerializedName("updateTime") 96 | val updateTime: Long, 97 | @SerializedName("userId") 98 | val userId: Long 99 | ) { 100 | data class Creator( 101 | @SerializedName("accountStatus") 102 | val accountStatus: Int, 103 | @SerializedName("anchor") 104 | val anchor: Boolean, 105 | @SerializedName("authStatus") 106 | val authStatus: Int, 107 | @SerializedName("authenticationTypes") 108 | val authenticationTypes: Int, 109 | @SerializedName("authority") 110 | val authority: Int, 111 | @SerializedName("avatarDetail") 112 | val avatarDetail: Any, 113 | @SerializedName("avatarImgId") 114 | val avatarImgId: Long, 115 | @SerializedName("avatarImgIdStr") 116 | val avatarImgIdStr: String, 117 | @SerializedName("avatarUrl") 118 | val avatarUrl: String, 119 | @SerializedName("backgroundImgId") 120 | val backgroundImgId: Long, 121 | @SerializedName("backgroundImgIdStr") 122 | val backgroundImgIdStr: String, 123 | @SerializedName("backgroundUrl") 124 | val backgroundUrl: String, 125 | @SerializedName("birthday") 126 | val birthday: Int, 127 | @SerializedName("city") 128 | val city: Int, 129 | @SerializedName("defaultAvatar") 130 | val defaultAvatar: Boolean, 131 | @SerializedName("description") 132 | val description: String, 133 | @SerializedName("detailDescription") 134 | val detailDescription: String, 135 | @SerializedName("djStatus") 136 | val djStatus: Int, 137 | @SerializedName("expertTags") 138 | val expertTags: Any, 139 | @SerializedName("experts") 140 | val experts: Any, 141 | @SerializedName("followed") 142 | val followed: Boolean, 143 | @SerializedName("gender") 144 | val gender: Int, 145 | @SerializedName("mutual") 146 | val mutual: Boolean, 147 | @SerializedName("nickname") 148 | val nickname: String, 149 | @SerializedName("province") 150 | val province: Int, 151 | @SerializedName("remarkName") 152 | val remarkName: Any, 153 | @SerializedName("signature") 154 | val signature: String, 155 | @SerializedName("userId") 156 | val userId: Long, 157 | @SerializedName("userType") 158 | val userType: Int, 159 | @SerializedName("vipType") 160 | val vipType: Int 161 | ) 162 | } 163 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/index/page/LibraryPage.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.index.page 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.foundation.lazy.items 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.rounded.Add 12 | import androidx.compose.material.icons.rounded.Menu 13 | import androidx.compose.material3.* 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.LaunchedEffect 16 | import androidx.compose.runtime.collectAsState 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.draw.clip 21 | import androidx.compose.ui.layout.ContentScale 22 | import androidx.compose.ui.unit.dp 23 | import coil.compose.rememberImagePainter 24 | import com.google.accompanist.swiperefresh.SwipeRefresh 25 | import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 26 | import me.rerere.rainmusic.data.retrofit.api.model.UserPlaylists 27 | import me.rerere.rainmusic.ui.component.shimmerPlaceholder 28 | import me.rerere.rainmusic.ui.local.LocalNavController 29 | import me.rerere.rainmusic.ui.local.LocalUserData 30 | import me.rerere.rainmusic.ui.screen.Screen 31 | import me.rerere.rainmusic.ui.screen.index.IndexViewModel 32 | import me.rerere.rainmusic.util.DataState 33 | 34 | @ExperimentalFoundationApi 35 | @Composable 36 | fun LibraryPage(indexViewModel: IndexViewModel) { 37 | val userData = LocalUserData.current 38 | val playlists by indexViewModel.userPlaylist.collectAsState() 39 | 40 | LaunchedEffect(userData) { 41 | if(playlists !is DataState.Success) { 42 | indexViewModel.refreshLibraryPage(userData.id) 43 | } 44 | } 45 | 46 | SwipeRefresh( 47 | state = rememberSwipeRefreshState(playlists is DataState.Loading), 48 | onRefresh = { 49 | indexViewModel.refreshLibraryPage(userData.id) 50 | } 51 | ) { 52 | LazyColumn( 53 | modifier = Modifier.fillMaxSize(), 54 | verticalArrangement = Arrangement.spacedBy(8.dp), 55 | contentPadding = PaddingValues(vertical = 8.dp) 56 | ) { 57 | playlists.readSafely()?.playlist?.let { 58 | it.groupBy { playlist -> 59 | playlist.creator.userId == userData.id 60 | }.forEach { (self, items) -> 61 | if (self) { 62 | stickyHeader { 63 | Surface(modifier = Modifier.fillMaxWidth()) { 64 | Row( 65 | modifier = Modifier.padding(horizontal = 16.dp), 66 | verticalAlignment = Alignment.CenterVertically 67 | ) { 68 | Text( 69 | text = "创建的歌单", 70 | style = MaterialTheme.typography.headlineSmall, 71 | modifier = Modifier.weight(1f) 72 | ) 73 | IconButton(onClick = { /*TODO*/ }) { 74 | Icon(Icons.Rounded.Add, null) 75 | } 76 | } 77 | } 78 | } 79 | } else { 80 | stickyHeader { 81 | Surface(modifier = Modifier.fillMaxWidth()) { 82 | Text( 83 | text = "收藏的歌单", 84 | style = MaterialTheme.typography.headlineSmall, 85 | modifier = Modifier.padding(horizontal = 16.dp) 86 | ) 87 | } 88 | } 89 | } 90 | 91 | items(items) { item -> 92 | PlayListItem(item) 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | @ExperimentalFoundationApi 101 | @Composable 102 | private fun PlayListItem(playlist: UserPlaylists.Playlist) { 103 | val navController = LocalNavController.current 104 | Surface( 105 | modifier = Modifier 106 | .padding(horizontal = 16.dp) 107 | .fillMaxWidth() 108 | .clickable { 109 | Screen.Playlist.navigate(navController) { 110 | addPath(playlist.id.toString()) 111 | } 112 | }, 113 | tonalElevation = 8.dp, 114 | shape = RoundedCornerShape(6.dp) 115 | ) { 116 | Row( 117 | modifier = Modifier 118 | .padding(8.dp) 119 | .height(IntrinsicSize.Min), 120 | horizontalArrangement = Arrangement.spacedBy(16.dp), 121 | verticalAlignment = Alignment.CenterVertically 122 | ) { 123 | val painter = rememberImagePainter( 124 | data = playlist.coverImgUrl 125 | ) 126 | Image( 127 | painter = painter, 128 | contentDescription = null, 129 | modifier = Modifier 130 | .clip(RoundedCornerShape(10)) 131 | .aspectRatio(1f) 132 | .heightIn(min = 100.dp) 133 | .fillMaxHeight() 134 | .shimmerPlaceholder(painter), 135 | contentScale = ContentScale.FillHeight 136 | ) 137 | 138 | Column( 139 | modifier = Modifier.weight(1f) 140 | ) { 141 | Text( 142 | text = playlist.name, 143 | style = MaterialTheme.typography.titleMedium, 144 | maxLines = 1 145 | ) 146 | Text( 147 | text = "${playlist.trackCount} 首音乐 ${playlist.playCount} 次播放", 148 | style = MaterialTheme.typography.labelMedium, 149 | maxLines = 1 150 | ) 151 | } 152 | 153 | IconButton(onClick = { 154 | // TODO: Playlist Actions 155 | }) { 156 | Icon(Icons.Rounded.Menu, null) 157 | } 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/MusicDetails.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class MusicDetails( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("privileges") 10 | val privileges: List, 11 | @SerializedName("songs") 12 | val songs: List 13 | ) { 14 | data class Privilege( 15 | @SerializedName("chargeInfoList") 16 | val chargeInfoList: List, 17 | @SerializedName("cp") 18 | val cp: Int, 19 | @SerializedName("cs") 20 | val cs: Boolean, 21 | @SerializedName("dl") 22 | val dl: Int, 23 | @SerializedName("downloadMaxbr") 24 | val downloadMaxbr: Int, 25 | @SerializedName("fee") 26 | val fee: Int, 27 | @SerializedName("fl") 28 | val fl: Int, 29 | @SerializedName("flag") 30 | val flag: Int, 31 | @SerializedName("freeTrialPrivilege") 32 | val freeTrialPrivilege: FreeTrialPrivilege, 33 | @SerializedName("id") 34 | val id: Int, 35 | @SerializedName("maxbr") 36 | val maxbr: Int, 37 | @SerializedName("payed") 38 | val payed: Int, 39 | @SerializedName("pl") 40 | val pl: Int, 41 | @SerializedName("playMaxbr") 42 | val playMaxbr: Int, 43 | @SerializedName("preSell") 44 | val preSell: Boolean, 45 | @SerializedName("rscl") 46 | val rscl: Any, 47 | @SerializedName("sp") 48 | val sp: Int, 49 | @SerializedName("st") 50 | val st: Int, 51 | @SerializedName("subp") 52 | val subp: Int, 53 | @SerializedName("toast") 54 | val toast: Boolean 55 | ) { 56 | data class ChargeInfo( 57 | @SerializedName("chargeMessage") 58 | val chargeMessage: Any, 59 | @SerializedName("chargeType") 60 | val chargeType: Int, 61 | @SerializedName("chargeUrl") 62 | val chargeUrl: Any, 63 | @SerializedName("rate") 64 | val rate: Int 65 | ) 66 | 67 | data class FreeTrialPrivilege( 68 | @SerializedName("resConsumable") 69 | val resConsumable: Boolean, 70 | @SerializedName("userConsumable") 71 | val userConsumable: Boolean 72 | ) 73 | } 74 | 75 | data class Song( 76 | @SerializedName("a") 77 | val a: Any, 78 | @SerializedName("al") 79 | val al: Al, 80 | @SerializedName("alia") 81 | val alia: List, 82 | @SerializedName("ar") 83 | val ar: List, 84 | @SerializedName("cd") 85 | val cd: String, 86 | @SerializedName("cf") 87 | val cf: String, 88 | @SerializedName("copyright") 89 | val copyright: Int, 90 | @SerializedName("cp") 91 | val cp: Int, 92 | @SerializedName("crbt") 93 | val crbt: Any, 94 | @SerializedName("djId") 95 | val djId: Int, 96 | @SerializedName("dt") 97 | val dt: Int, 98 | @SerializedName("fee") 99 | val fee: Int, 100 | @SerializedName("ftype") 101 | val ftype: Int, 102 | @SerializedName("h") 103 | val h: H, 104 | @SerializedName("id") 105 | val id: Long, 106 | @SerializedName("l") 107 | val l: L, 108 | @SerializedName("m") 109 | val m: M, 110 | @SerializedName("mark") 111 | val mark: Long, 112 | @SerializedName("mst") 113 | val mst: Int, 114 | @SerializedName("mv") 115 | val mv: Int, 116 | @SerializedName("name") 117 | val name: String, 118 | @SerializedName("no") 119 | val no: Int, 120 | @SerializedName("noCopyrightRcmd") 121 | val noCopyrightRcmd: Any, 122 | @SerializedName("originCoverType") 123 | val originCoverType: Int, 124 | @SerializedName("originSongSimpleData") 125 | val originSongSimpleData: Any, 126 | @SerializedName("pop") 127 | val pop: Double, 128 | @SerializedName("pst") 129 | val pst: Int, 130 | @SerializedName("publishTime") 131 | val publishTime: Long, 132 | @SerializedName("resourceState") 133 | val resourceState: Boolean, 134 | @SerializedName("rt") 135 | val rt: String, 136 | @SerializedName("rtUrl") 137 | val rtUrl: Any, 138 | @SerializedName("rtUrls") 139 | val rtUrls: List, 140 | @SerializedName("rtype") 141 | val rtype: Int, 142 | @SerializedName("rurl") 143 | val rurl: Any, 144 | @SerializedName("s_id") 145 | val sId: Int, 146 | @SerializedName("single") 147 | val single: Int, 148 | @SerializedName("songJumpInfo") 149 | val songJumpInfo: Any, 150 | @SerializedName("st") 151 | val st: Int, 152 | @SerializedName("t") 153 | val t: Int, 154 | @SerializedName("tagPicList") 155 | val tagPicList: Any, 156 | @SerializedName("v") 157 | val v: Int, 158 | @SerializedName("version") 159 | val version: Int 160 | ) { 161 | data class Al( 162 | @SerializedName("id") 163 | val id: Int, 164 | @SerializedName("name") 165 | val name: String, 166 | @SerializedName("pic") 167 | val pic: Long, 168 | @SerializedName("pic_str") 169 | val picStr: String, 170 | @SerializedName("picUrl") 171 | val picUrl: String, 172 | @SerializedName("tns") 173 | val tns: List 174 | ) 175 | 176 | data class Ar( 177 | @SerializedName("alias") 178 | val alias: List, 179 | @SerializedName("id") 180 | val id: Int, 181 | @SerializedName("name") 182 | val name: String, 183 | @SerializedName("tns") 184 | val tns: List 185 | ) 186 | 187 | data class H( 188 | @SerializedName("br") 189 | val br: Int, 190 | @SerializedName("fid") 191 | val fid: Int, 192 | @SerializedName("size") 193 | val size: Int, 194 | @SerializedName("vd") 195 | val vd: Double 196 | ) 197 | 198 | data class L( 199 | @SerializedName("br") 200 | val br: Int, 201 | @SerializedName("fid") 202 | val fid: Int, 203 | @SerializedName("size") 204 | val size: Int, 205 | @SerializedName("vd") 206 | val vd: Double 207 | ) 208 | 209 | data class M( 210 | @SerializedName("br") 211 | val br: Int, 212 | @SerializedName("fid") 213 | val fid: Int, 214 | @SerializedName("size") 215 | val size: Int, 216 | @SerializedName("vd") 217 | val vd: Double 218 | ) 219 | } 220 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/login/LoginScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.login 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.text.KeyboardOptions 7 | import androidx.compose.material.ContentAlpha 8 | import androidx.compose.material.LocalContentAlpha 9 | import androidx.compose.material.OutlinedTextField 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Visibility 12 | import androidx.compose.material.icons.filled.VisibilityOff 13 | import androidx.compose.material3.* 14 | import androidx.compose.runtime.* 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.res.painterResource 19 | import androidx.compose.ui.text.input.KeyboardType 20 | import androidx.compose.ui.text.input.PasswordVisualTransformation 21 | import androidx.compose.ui.text.input.VisualTransformation 22 | import androidx.compose.ui.unit.dp 23 | import androidx.hilt.navigation.compose.hiltViewModel 24 | import dev.burnoo.compose.rememberpreference.rememberStringPreference 25 | import me.rerere.rainmusic.R 26 | import me.rerere.rainmusic.RouteActivity 27 | import me.rerere.rainmusic.ui.component.RainTopBar 28 | import me.rerere.rainmusic.ui.local.LocalNavController 29 | import me.rerere.rainmusic.util.toast 30 | 31 | @ExperimentalMaterial3Api 32 | @Composable 33 | fun LoginScreen( 34 | loginViewModel: LoginViewModel = hiltViewModel() 35 | ) { 36 | Scaffold( 37 | topBar = { 38 | RainTopBar( 39 | title = { 40 | Text(text = "登录") 41 | } 42 | ) 43 | } 44 | ) { 45 | Box( 46 | modifier = Modifier 47 | .padding(it) 48 | .fillMaxSize() 49 | .navigationBarsPadding(), 50 | contentAlignment = Alignment.Center 51 | ) { 52 | Body( 53 | loginViewModel 54 | ) 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | private fun Body( 61 | loginViewModel: LoginViewModel 62 | ) { 63 | val context = LocalContext.current 64 | val navController = LocalNavController.current 65 | val loginState by loginViewModel.loginState.collectAsState() 66 | var showDialog by remember { 67 | mutableStateOf(false) 68 | } 69 | 70 | AnimatedVisibility(showDialog) { 71 | AlertDialog( 72 | onDismissRequest = { showDialog = false }, 73 | confirmButton = { 74 | TextButton(onClick = { showDialog = false }) { 75 | Text(text = "关闭") 76 | } 77 | }, 78 | title = { 79 | Text(text = "登录") 80 | }, 81 | text = { 82 | when (loginState) { 83 | 1 -> { 84 | Text("登录中, 请稍等...") 85 | } 86 | -1 -> { 87 | Text("登录时发生错误,请检查你的网络连接") 88 | } 89 | 2 -> { 90 | Text("密码错误!") 91 | } 92 | 3 -> { 93 | Text("没有此账号!") 94 | } 95 | 1000 -> { 96 | Text("登录成功") 97 | } 98 | else -> { 99 | Text("未知错误: $loginState") 100 | } 101 | } 102 | } 103 | ) 104 | } 105 | 106 | LaunchedEffect(loginState) { 107 | showDialog = loginState != 0 108 | if (loginState == 1000) { 109 | // 登录成功 110 | context.toast("登录成功") 111 | navController.navigate("index") { 112 | popUpTo("login") { 113 | inclusive = true 114 | } 115 | } 116 | (context as RouteActivity).retryInit() 117 | } 118 | } 119 | 120 | var username by rememberStringPreference( 121 | keyName = "login.phone", 122 | defaultValue = "", 123 | initialValue = "" 124 | ) 125 | var password by rememberStringPreference( 126 | keyName = "login.password", 127 | defaultValue = "", 128 | initialValue = "" 129 | ) 130 | Column( 131 | modifier = Modifier.width(IntrinsicSize.Min), 132 | horizontalAlignment = Alignment.CenterHorizontally, 133 | verticalArrangement = Arrangement.spacedBy(16.dp) 134 | ) { 135 | Image( 136 | painter = painterResource(R.drawable.netease_music), 137 | contentDescription = null, 138 | modifier = Modifier 139 | .padding(32.dp) 140 | .size(100.dp) 141 | ) 142 | 143 | OutlinedTextField( 144 | value = username, 145 | onValueChange = { 146 | username = it.let { 147 | if (it.length > 11) { 148 | it.substring(0..10) 149 | } else { 150 | it 151 | } 152 | } 153 | }, 154 | singleLine = true, 155 | label = { 156 | Text(text = "手机号") 157 | }, 158 | leadingIcon = { 159 | Text( 160 | text = "+86", 161 | color = MaterialTheme.colorScheme.primary 162 | ) 163 | }, 164 | keyboardOptions = KeyboardOptions( 165 | keyboardType = KeyboardType.Number 166 | ) 167 | ) 168 | 169 | var passwordVisible by remember { 170 | mutableStateOf(false) 171 | } 172 | OutlinedTextField( 173 | value = password, 174 | onValueChange = { 175 | password = it 176 | }, 177 | label = { 178 | Text(text = "密码") 179 | }, 180 | visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), 181 | trailingIcon = { 182 | IconButton(onClick = { 183 | passwordVisible = !passwordVisible 184 | }) { 185 | Icon( 186 | imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, 187 | contentDescription = null 188 | ) 189 | } 190 | }, 191 | singleLine = true, 192 | keyboardOptions = KeyboardOptions( 193 | keyboardType = KeyboardType.Password 194 | ) 195 | ) 196 | 197 | Button( 198 | modifier = Modifier.fillMaxWidth(), 199 | onClick = { 200 | loginViewModel.loginCellPhone( 201 | phone = username, 202 | password = password 203 | ) 204 | } 205 | ) { 206 | Text(text = "登录") 207 | } 208 | 209 | CompositionLocalProvider( 210 | LocalContentAlpha provides ContentAlpha.medium 211 | ) { 212 | Text( 213 | modifier = Modifier.padding(16.dp), 214 | text = "本APP不提供 注册/开通黑胶/修改个人信息 等功能,如果需要,请自行前往网易云官方APP操作!" 215 | ) 216 | } 217 | } 218 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/model/Playlist.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.model 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class Playlists( 6 | @SerializedName("adType") 7 | val adType: Int, 8 | @SerializedName("alg") 9 | val alg: String, 10 | @SerializedName("anonimous") 11 | val anonimous: Boolean, 12 | @SerializedName("cloudTrackCount") 13 | val cloudTrackCount: Int, 14 | @SerializedName("commentCount") 15 | val commentCount: Int, 16 | @SerializedName("commentThreadId") 17 | val commentThreadId: String, 18 | @SerializedName("coverImgId") 19 | val coverImgId: Long, 20 | @SerializedName("coverImgId_str") 21 | val coverImgIdStr: String, 22 | @SerializedName("coverImgUrl") 23 | val coverImgUrl: String, 24 | @SerializedName("coverStatus") 25 | val coverStatus: Int, 26 | @SerializedName("createTime") 27 | val createTime: Long, 28 | @SerializedName("creator") 29 | val creator: Creator, 30 | @SerializedName("description") 31 | val description: String, 32 | @SerializedName("highQuality") 33 | val highQuality: Boolean, 34 | @SerializedName("id") 35 | val id: Long, 36 | @SerializedName("name") 37 | val name: String, 38 | @SerializedName("newImported") 39 | val newImported: Boolean, 40 | @SerializedName("ordered") 41 | val ordered: Boolean, 42 | @SerializedName("playCount") 43 | val playCount: Long, 44 | @SerializedName("privacy") 45 | val privacy: Int, 46 | @SerializedName("recommendInfo") 47 | val recommendInfo: Any, 48 | @SerializedName("shareCount") 49 | val shareCount: Int, 50 | @SerializedName("specialType") 51 | val specialType: Int, 52 | @SerializedName("status") 53 | val status: Int, 54 | @SerializedName("subscribed") 55 | val subscribed: Boolean, 56 | @SerializedName("subscribedCount") 57 | val subscribedCount: Int, 58 | @SerializedName("subscribers") 59 | val subscribers: List, 60 | @SerializedName("tags") 61 | val tags: List, 62 | @SerializedName("totalDuration") 63 | val totalDuration: Int, 64 | @SerializedName("trackCount") 65 | val trackCount: Int, 66 | @SerializedName("trackNumberUpdateTime") 67 | val trackNumberUpdateTime: Long, 68 | @SerializedName("trackUpdateTime") 69 | val trackUpdateTime: Long, 70 | @SerializedName("tracks") 71 | val tracks: Any, 72 | @SerializedName("updateTime") 73 | val updateTime: Long, 74 | @SerializedName("userId") 75 | val userId: Long 76 | ) { 77 | data class Creator( 78 | @SerializedName("accountStatus") 79 | val accountStatus: Int, 80 | @SerializedName("anchor") 81 | val anchor: Boolean, 82 | @SerializedName("authStatus") 83 | val authStatus: Int, 84 | @SerializedName("authenticationTypes") 85 | val authenticationTypes: Int, 86 | @SerializedName("authority") 87 | val authority: Int, 88 | @SerializedName("avatarDetail") 89 | val avatarDetail: AvatarDetail, 90 | @SerializedName("avatarImgId") 91 | val avatarImgId: Long, 92 | @SerializedName("avatarImgIdStr") 93 | val avatarImgIdStr: String, 94 | @SerializedName("avatarUrl") 95 | val avatarUrl: String, 96 | @SerializedName("backgroundImgId") 97 | val backgroundImgId: Long, 98 | @SerializedName("backgroundImgIdStr") 99 | val backgroundImgIdStr: String, 100 | @SerializedName("backgroundUrl") 101 | val backgroundUrl: String, 102 | @SerializedName("birthday") 103 | val birthday: Long, 104 | @SerializedName("city") 105 | val city: Int, 106 | @SerializedName("defaultAvatar") 107 | val defaultAvatar: Boolean, 108 | @SerializedName("description") 109 | val description: String, 110 | @SerializedName("detailDescription") 111 | val detailDescription: String, 112 | @SerializedName("djStatus") 113 | val djStatus: Int, 114 | @SerializedName("expertTags") 115 | val expertTags: Any, 116 | @SerializedName("experts") 117 | val experts: Any, 118 | @SerializedName("followed") 119 | val followed: Boolean, 120 | @SerializedName("gender") 121 | val gender: Int, 122 | @SerializedName("mutual") 123 | val mutual: Boolean, 124 | @SerializedName("nickname") 125 | val nickname: String, 126 | @SerializedName("province") 127 | val province: Int, 128 | @SerializedName("remarkName") 129 | val remarkName: Any, 130 | @SerializedName("signature") 131 | val signature: String, 132 | @SerializedName("userId") 133 | val userId: Long, 134 | @SerializedName("userType") 135 | val userType: Int, 136 | @SerializedName("vipType") 137 | val vipType: Int 138 | ) { 139 | data class AvatarDetail( 140 | @SerializedName("identityIconUrl") 141 | val identityIconUrl: String, 142 | @SerializedName("identityLevel") 143 | val identityLevel: Int, 144 | @SerializedName("userType") 145 | val userType: Int 146 | ) 147 | } 148 | 149 | data class Subscriber( 150 | @SerializedName("accountStatus") 151 | val accountStatus: Int, 152 | @SerializedName("anchor") 153 | val anchor: Boolean, 154 | @SerializedName("authStatus") 155 | val authStatus: Int, 156 | @SerializedName("authenticationTypes") 157 | val authenticationTypes: Int, 158 | @SerializedName("authority") 159 | val authority: Int, 160 | @SerializedName("avatarDetail") 161 | val avatarDetail: Any, 162 | @SerializedName("avatarImgId") 163 | val avatarImgId: Long, 164 | @SerializedName("avatarImgIdStr") 165 | val avatarImgIdStr: String, 166 | @SerializedName("avatarUrl") 167 | val avatarUrl: String, 168 | @SerializedName("backgroundImgId") 169 | val backgroundImgId: Long, 170 | @SerializedName("backgroundImgIdStr") 171 | val backgroundImgIdStr: String, 172 | @SerializedName("backgroundUrl") 173 | val backgroundUrl: String, 174 | @SerializedName("birthday") 175 | val birthday: Long, 176 | @SerializedName("city") 177 | val city: Int, 178 | @SerializedName("defaultAvatar") 179 | val defaultAvatar: Boolean, 180 | @SerializedName("description") 181 | val description: String, 182 | @SerializedName("detailDescription") 183 | val detailDescription: String, 184 | @SerializedName("djStatus") 185 | val djStatus: Int, 186 | @SerializedName("expertTags") 187 | val expertTags: Any, 188 | @SerializedName("experts") 189 | val experts: Any, 190 | @SerializedName("followed") 191 | val followed: Boolean, 192 | @SerializedName("gender") 193 | val gender: Int, 194 | @SerializedName("mutual") 195 | val mutual: Boolean, 196 | @SerializedName("nickname") 197 | val nickname: String, 198 | @SerializedName("province") 199 | val province: Int, 200 | @SerializedName("remarkName") 201 | val remarkName: Any, 202 | @SerializedName("signature") 203 | val signature: String, 204 | @SerializedName("userId") 205 | val userId: Long, 206 | @SerializedName("userType") 207 | val userType: Int, 208 | @SerializedName("vipType") 209 | val vipType: Int 210 | ) 211 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/dailysong/DailySongScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.dailysong 2 | 3 | import android.net.Uri 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.lazy.itemsIndexed 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.rounded.PlayArrow 10 | import androidx.compose.material3.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.input.nestedscroll.nestedScroll 17 | import androidx.compose.ui.platform.LocalContext 18 | import androidx.compose.ui.unit.dp 19 | import androidx.hilt.navigation.compose.hiltViewModel 20 | import me.rerere.rainmusic.data.retrofit.api.model.DailyRecommendSongs 21 | import me.rerere.rainmusic.service.MusicService 22 | import me.rerere.rainmusic.ui.component.AppBarStyle 23 | import me.rerere.rainmusic.ui.component.PopBackIcon 24 | import me.rerere.rainmusic.ui.component.RainTopBar 25 | import me.rerere.rainmusic.ui.component.shimmerPlaceholder 26 | import me.rerere.rainmusic.ui.states.asyncGetSessionPlayer 27 | import me.rerere.rainmusic.util.DataState 28 | import me.rerere.rainmusic.util.RainMusicProtocol 29 | import me.rerere.rainmusic.util.media.buildMediaItem 30 | import me.rerere.rainmusic.util.media.metadata 31 | 32 | @OptIn(ExperimentalMaterial3Api::class) 33 | @Composable 34 | fun DailySongScreen( 35 | dailySongViewModel: DailySongViewModel = hiltViewModel() 36 | ) { 37 | val dailySongs by dailySongViewModel.dailySongs.collectAsState() 38 | val context = LocalContext.current 39 | val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() 40 | Scaffold( 41 | topBar = { 42 | RainTopBar( 43 | title = { 44 | Text(text = "每日推荐") 45 | }, 46 | navigationIcon = { 47 | PopBackIcon() 48 | }, 49 | actions = { 50 | IconButton(onClick = { 51 | context.asyncGetSessionPlayer(MusicService::class.java) { 52 | it.apply { 53 | stop() 54 | clearMediaItems() 55 | dailySongs.readSafely()?.data?.dailySongs?.forEach { track -> 56 | addMediaItem( 57 | buildMediaItem(track.id.toString()) { 58 | metadata { 59 | setTitle(track.name) 60 | setArtist(track.ar.joinToString(", ") { ar -> ar.name }) 61 | setMediaUri(Uri.parse("$RainMusicProtocol://music?id=${track.id}")) 62 | setArtworkUri(Uri.parse(track.al.picUrl)) 63 | } 64 | } 65 | ) 66 | } 67 | prepare() 68 | play() 69 | } 70 | } 71 | }) { 72 | Icon(Icons.Rounded.PlayArrow, null) 73 | } 74 | }, 75 | scrollBehavior = scrollBehavior, 76 | appBarStyle = AppBarStyle.Large 77 | ) 78 | } 79 | ) { 80 | LazyColumn( 81 | modifier = Modifier 82 | .fillMaxSize() 83 | .nestedScroll(scrollBehavior.nestedScrollConnection), 84 | verticalArrangement = Arrangement.spacedBy(8.dp), 85 | contentPadding = WindowInsets.navigationBars.asPaddingValues() 86 | ){ 87 | when(dailySongs){ 88 | is DataState.Success -> { 89 | dailySongs.readSafely()?.data?.dailySongs?.let { 90 | itemsIndexed(it){ index, item -> 91 | PlaylistMusic(index + 1, item) 92 | } 93 | } 94 | } 95 | is DataState.Loading -> { 96 | items(5){ 97 | Box(modifier = Modifier 98 | .padding(16.dp) 99 | .fillMaxWidth() 100 | .height(80.dp) 101 | .shimmerPlaceholder(true) 102 | ) 103 | } 104 | } 105 | is DataState.Error -> { 106 | item { 107 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center){ 108 | Text(text = "加载失败") 109 | } 110 | } 111 | } 112 | else -> {} 113 | } 114 | } 115 | } 116 | } 117 | 118 | @Composable 119 | private fun PlaylistMusic( 120 | index: Int, 121 | track: DailyRecommendSongs.Data.DailySong 122 | ) { 123 | val context = LocalContext.current 124 | Surface( 125 | modifier = Modifier 126 | .fillMaxWidth() 127 | .padding(horizontal = 12.dp), 128 | tonalElevation = if (index % 2 == 0) 8.dp else 16.dp, 129 | shape = RoundedCornerShape(6.dp) 130 | ) { 131 | Row( 132 | modifier = Modifier.padding(8.dp), 133 | horizontalArrangement = Arrangement.spacedBy(16.dp), 134 | verticalAlignment = Alignment.CenterVertically 135 | ) { 136 | Text(text = index.toString()) 137 | 138 | Column( 139 | modifier = Modifier.weight(1f) 140 | ) { 141 | Text( 142 | text = track.name, 143 | style = MaterialTheme.typography.titleMedium 144 | ) 145 | Row { 146 | Text( 147 | text = track.ar.joinToString(separator = "/") { it.name } + if (track.al.name.isNotBlank()) " - ${track.al.name}" else "", 148 | style = MaterialTheme.typography.labelMedium 149 | ) 150 | } 151 | } 152 | 153 | IconButton(onClick = { 154 | context.asyncGetSessionPlayer(MusicService::class.java) { 155 | it.apply { 156 | stop() 157 | clearMediaItems() 158 | addMediaItem( 159 | buildMediaItem(track.id.toString()) { 160 | metadata { 161 | setTitle(track.name) 162 | setArtist(track.ar.joinToString(", ") { ar -> ar.name }) 163 | setMediaUri(Uri.parse("$RainMusicProtocol://music?id=${track.id}")) 164 | setArtworkUri(Uri.parse(track.al.picUrl)) 165 | } 166 | } 167 | ) 168 | prepare() 169 | play() 170 | } 171 | } 172 | }) { 173 | Icon(Icons.Rounded.PlayArrow, null) 174 | } 175 | } 176 | } 177 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/data/retrofit/api/model/DailyRecommendSongs.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.data.retrofit.api.model 2 | 3 | 4 | import com.google.gson.annotations.SerializedName 5 | 6 | data class DailyRecommendSongs( 7 | @SerializedName("code") 8 | val code: Int, 9 | @SerializedName("data") 10 | val `data`: Data 11 | ) { 12 | data class Data( 13 | @SerializedName("dailySongs") 14 | val dailySongs: List, 15 | @SerializedName("orderSongs") 16 | val orderSongs: List, 17 | @SerializedName("recommendReasons") 18 | val recommendReasons: List 19 | ) { 20 | data class DailySong( 21 | @SerializedName("a") 22 | val a: Any, 23 | @SerializedName("al") 24 | val al: Al, 25 | @SerializedName("alg") 26 | val alg: String, 27 | @SerializedName("alia") 28 | val alia: List, 29 | @SerializedName("ar") 30 | val ar: List, 31 | @SerializedName("cd") 32 | val cd: String, 33 | @SerializedName("cf") 34 | val cf: String, 35 | @SerializedName("copyright") 36 | val copyright: Int, 37 | @SerializedName("cp") 38 | val cp: Int, 39 | @SerializedName("crbt") 40 | val crbt: Any, 41 | @SerializedName("djId") 42 | val djId: Long, 43 | @SerializedName("dt") 44 | val dt: Int, 45 | @SerializedName("fee") 46 | val fee: Int, 47 | @SerializedName("ftype") 48 | val ftype: Int, 49 | @SerializedName("h") 50 | val h: H, 51 | @SerializedName("id") 52 | val id: Long, 53 | @SerializedName("l") 54 | val l: L, 55 | @SerializedName("m") 56 | val m: M, 57 | @SerializedName("mark") 58 | val mark: Long, 59 | @SerializedName("mst") 60 | val mst: Int, 61 | @SerializedName("mv") 62 | val mv: Int, 63 | @SerializedName("name") 64 | val name: String, 65 | @SerializedName("no") 66 | val no: Int, 67 | @SerializedName("noCopyrightRcmd") 68 | val noCopyrightRcmd: Any, 69 | @SerializedName("originCoverType") 70 | val originCoverType: Int, 71 | @SerializedName("originSongSimpleData") 72 | val originSongSimpleData: Any, 73 | @SerializedName("pop") 74 | val pop: Double, 75 | @SerializedName("privilege") 76 | val privilege: Privilege, 77 | @SerializedName("pst") 78 | val pst: Int, 79 | @SerializedName("publishTime") 80 | val publishTime: Long, 81 | @SerializedName("reason") 82 | val reason: String, 83 | @SerializedName("rt") 84 | val rt: String, 85 | @SerializedName("rtUrl") 86 | val rtUrl: Any, 87 | @SerializedName("rtUrls") 88 | val rtUrls: List, 89 | @SerializedName("rtype") 90 | val rtype: Int, 91 | @SerializedName("rurl") 92 | val rurl: Any, 93 | @SerializedName("s_id") 94 | val sId: Int, 95 | @SerializedName("single") 96 | val single: Int, 97 | @SerializedName("st") 98 | val st: Int, 99 | @SerializedName("t") 100 | val t: Int, 101 | @SerializedName("tns") 102 | val tns: List, 103 | @SerializedName("v") 104 | val v: Int 105 | ) { 106 | data class Al( 107 | @SerializedName("id") 108 | val id: Int, 109 | @SerializedName("name") 110 | val name: String, 111 | @SerializedName("pic") 112 | val pic: Long, 113 | @SerializedName("pic_str") 114 | val picStr: String, 115 | @SerializedName("picUrl") 116 | val picUrl: String, 117 | @SerializedName("tns") 118 | val tns: List 119 | ) 120 | 121 | data class Ar( 122 | @SerializedName("alias") 123 | val alias: List, 124 | @SerializedName("id") 125 | val id: Int, 126 | @SerializedName("name") 127 | val name: String, 128 | @SerializedName("tns") 129 | val tns: List 130 | ) 131 | 132 | data class H( 133 | @SerializedName("br") 134 | val br: Int, 135 | @SerializedName("fid") 136 | val fid: Int, 137 | @SerializedName("size") 138 | val size: Int, 139 | @SerializedName("vd") 140 | val vd: Double 141 | ) 142 | 143 | data class L( 144 | @SerializedName("br") 145 | val br: Int, 146 | @SerializedName("fid") 147 | val fid: Int, 148 | @SerializedName("size") 149 | val size: Int, 150 | @SerializedName("vd") 151 | val vd: Double 152 | ) 153 | 154 | data class M( 155 | @SerializedName("br") 156 | val br: Int, 157 | @SerializedName("fid") 158 | val fid: Int, 159 | @SerializedName("size") 160 | val size: Int, 161 | @SerializedName("vd") 162 | val vd: Double 163 | ) 164 | 165 | data class Privilege( 166 | @SerializedName("chargeInfoList") 167 | val chargeInfoList: List, 168 | @SerializedName("cp") 169 | val cp: Int, 170 | @SerializedName("cs") 171 | val cs: Boolean, 172 | @SerializedName("dl") 173 | val dl: Int, 174 | @SerializedName("downloadMaxbr") 175 | val downloadMaxbr: Int, 176 | @SerializedName("fee") 177 | val fee: Int, 178 | @SerializedName("fl") 179 | val fl: Int, 180 | @SerializedName("flag") 181 | val flag: Int, 182 | @SerializedName("freeTrialPrivilege") 183 | val freeTrialPrivilege: FreeTrialPrivilege, 184 | @SerializedName("id") 185 | val id: Int, 186 | @SerializedName("maxbr") 187 | val maxbr: Int, 188 | @SerializedName("payed") 189 | val payed: Int, 190 | @SerializedName("pl") 191 | val pl: Int, 192 | @SerializedName("playMaxbr") 193 | val playMaxbr: Int, 194 | @SerializedName("preSell") 195 | val preSell: Boolean, 196 | @SerializedName("rscl") 197 | val rscl: Any, 198 | @SerializedName("sp") 199 | val sp: Int, 200 | @SerializedName("st") 201 | val st: Int, 202 | @SerializedName("subp") 203 | val subp: Int, 204 | @SerializedName("toast") 205 | val toast: Boolean 206 | ) { 207 | data class ChargeInfo( 208 | @SerializedName("chargeMessage") 209 | val chargeMessage: Any, 210 | @SerializedName("chargeType") 211 | val chargeType: Int, 212 | @SerializedName("chargeUrl") 213 | val chargeUrl: Any, 214 | @SerializedName("rate") 215 | val rate: Int 216 | ) 217 | 218 | data class FreeTrialPrivilege( 219 | @SerializedName("resConsumable") 220 | val resConsumable: Boolean, 221 | @SerializedName("userConsumable") 222 | val userConsumable: Boolean 223 | ) 224 | } 225 | } 226 | 227 | data class RecommendReason( 228 | @SerializedName("reason") 229 | val reason: String, 230 | @SerializedName("songId") 231 | val songId: Int 232 | ) 233 | } 234 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/repo/MusicRepo.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.repo 2 | 3 | import com.google.gson.JsonObject 4 | import kotlinx.coroutines.flow.flow 5 | import me.rerere.rainmusic.data.retrofit.api.NeteaseMusicApi 6 | import me.rerere.rainmusic.data.retrofit.api.model.LikeResult 7 | import me.rerere.rainmusic.data.retrofit.eapi.NeteaseMusicEApi 8 | import me.rerere.rainmusic.data.retrofit.weapi.NeteaseMusicWeApi 9 | import me.rerere.rainmusic.data.retrofit.weapi.model.HotPlaylistTag 10 | import me.rerere.rainmusic.data.retrofit.weapi.model.SubPlaylistResult 11 | import me.rerere.rainmusic.util.DataState 12 | import me.rerere.rainmusic.util.encrypt.encryptEApi 13 | import me.rerere.rainmusic.util.encrypt.encryptWeAPI 14 | import me.rerere.rainmusic.util.requireOneOf 15 | import javax.inject.Inject 16 | 17 | class MusicRepo @Inject constructor( 18 | private val api: NeteaseMusicApi, 19 | private val eApi: NeteaseMusicEApi, 20 | private val weApi: NeteaseMusicWeApi 21 | ) { 22 | fun getPersonalizedPlaylist( 23 | limit: Int = 10 24 | ) = flow { 25 | emit(DataState.Loading) 26 | try { 27 | val result = weApi.personalizedPlaylist( 28 | encryptWeAPI( 29 | mapOf( 30 | "limit" to limit.toString(), 31 | "total" to "true", 32 | "n" to "1000" 33 | ) 34 | ) 35 | ) 36 | emit(DataState.Success(result)) 37 | } catch (e: Exception) { 38 | e.printStackTrace() 39 | emit(DataState.Error(e)) 40 | } 41 | } 42 | 43 | fun getNewSongs( 44 | limit: Int = 10, 45 | areaId: Int = 0 46 | ) = flow { 47 | emit(DataState.Loading) 48 | try { 49 | val result = weApi.getNewSongs( 50 | encryptWeAPI( 51 | mapOf( 52 | "type" to "recommend", 53 | "limit" to limit.toString(), 54 | "areaId" to areaId.toString() 55 | ) 56 | ) 57 | ) 58 | emit(DataState.Success(result)) 59 | } catch (e: Exception) { 60 | e.printStackTrace() 61 | emit(DataState.Error(e)) 62 | } 63 | } 64 | 65 | fun getPlaylistDetail( 66 | id: Long 67 | ) = flow { 68 | emit(DataState.Loading) 69 | try { 70 | val result = api.getPlaylistDetail( 71 | mapOf( 72 | "id" to id.toString(), 73 | "n" to "5000", // 歌单返回的最多歌曲数量? 74 | "s" to "8" 75 | ) 76 | ) 77 | emit(DataState.Success(result)) 78 | } catch (e: Exception) { 79 | e.printStackTrace() 80 | emit(DataState.Error(e)) 81 | } 82 | } 83 | 84 | fun getMusicUrl( 85 | id: Long, 86 | bitRate: Int = 999000 87 | ) = flow { 88 | emit(DataState.Loading) 89 | try { 90 | val result = eApi.getMusicUrl( 91 | encryptEApi( 92 | url = "/api/song/enhance/player/url", 93 | data = mapOf( 94 | "ids" to "[$id]", 95 | "br" to bitRate.toString() 96 | ) 97 | ) 98 | ) 99 | emit(DataState.Success(result)) 100 | } catch (e: Exception) { 101 | e.printStackTrace() 102 | emit(DataState.Error(e)) 103 | } 104 | } 105 | 106 | fun getMusicDetail( 107 | id: Long 108 | ) = flow { 109 | emit(DataState.Loading) 110 | try { 111 | val result = api.getMusicDetail( 112 | mapOf( 113 | "c" to "[{\"id\":$id}]" 114 | ) 115 | ) 116 | emit(DataState.Success(result)) 117 | } catch (e: Exception) { 118 | e.printStackTrace() 119 | emit(DataState.Error(e)) 120 | } 121 | } 122 | 123 | fun getLyric(id: Long) = flow { 124 | emit(DataState.Loading) 125 | try { 126 | val result = api.getLyric( 127 | mapOf( 128 | "id" to id.toString(), 129 | "lv" to "-1", 130 | "kv" to "-1", 131 | "tv" to "-1" 132 | ) 133 | ) 134 | emit(DataState.Success(result)) 135 | } catch (e: Exception) { 136 | e.printStackTrace() 137 | emit(DataState.Error(e)) 138 | } 139 | } 140 | 141 | fun getTopList() = flow { 142 | emit(DataState.Loading) 143 | try { 144 | val result = api.getAllTopList() 145 | emit(DataState.Success(result)) 146 | } catch (e: Exception) { 147 | e.printStackTrace() 148 | emit(DataState.Error(e)) 149 | } 150 | } 151 | 152 | fun getDailyRecommendSongs() = flow { 153 | emit(DataState.Loading) 154 | try { 155 | val result = api.getDailyRecommendSongList() 156 | emit(DataState.Success(result)) 157 | } catch (e: Exception) { 158 | e.printStackTrace() 159 | emit(DataState.Error(e)) 160 | } 161 | } 162 | 163 | fun getPlaylistCategory() = flow { 164 | emit(DataState.Loading) 165 | try { 166 | val result = weApi.getPlaylistCat( 167 | encryptWeAPI( 168 | mapOf() 169 | ) 170 | ) 171 | emit(DataState.Success(result)) 172 | } catch (e: Exception) { 173 | e.printStackTrace() 174 | emit(DataState.Error(e)) 175 | } 176 | } 177 | 178 | fun getTopPlaylist( 179 | category: String, 180 | order: String = "hot", 181 | limit: Int = 50, 182 | offset: Int = 0 183 | ) = flow { 184 | emit(DataState.Loading) 185 | try { 186 | val result = weApi.getTopPlaylist( 187 | encryptWeAPI( 188 | mapOf( 189 | "cat" to category, 190 | "order" to order, 191 | "limit" to limit.toString(), 192 | "offset" to offset.toString(), 193 | "total" to "true" 194 | ) 195 | ) 196 | ) 197 | emit(DataState.Success(result)) 198 | } catch (e: Exception) { 199 | e.printStackTrace() 200 | emit(DataState.Error(e)) 201 | } 202 | } 203 | 204 | suspend fun getHotPlaylistTags(): HotPlaylistTag? { 205 | return try { 206 | weApi.getHotPlaylistTags( 207 | encryptWeAPI( 208 | mapOf() 209 | ) 210 | ) 211 | } catch (e: Exception) { 212 | e.printStackTrace() 213 | null 214 | } 215 | } 216 | 217 | fun getHighQualityPlaylist( 218 | cat: String, 219 | limit: Int = 20 220 | ) = flow { 221 | emit(DataState.Loading) 222 | try { 223 | val result = api.getHighQualityPlaylist( 224 | mapOf( 225 | "cat" to cat, 226 | "limit" to limit.toString(), 227 | "lasttime" to "0", 228 | "total" to "true" 229 | ) 230 | ) 231 | emit(DataState.Success(result)) 232 | } catch (e: Exception) { 233 | e.printStackTrace() 234 | emit(DataState.Error(e)) 235 | } 236 | } 237 | 238 | /** 239 | * 订阅歌单 240 | */ 241 | suspend fun subPlaylist( 242 | playlistId: Long, 243 | sub: Boolean 244 | ): SubPlaylistResult? = try { 245 | weApi.subPlaylist( 246 | action = if (sub) "subscribe" else "unsubscribe", 247 | body = encryptWeAPI( 248 | mapOf( 249 | "id" to playlistId.toString() 250 | ) 251 | ) 252 | ) 253 | } catch (e: Exception) { 254 | e.printStackTrace() 255 | null 256 | } 257 | 258 | suspend fun likeMusic( 259 | musicId: Long, 260 | like: Boolean 261 | ): LikeResult? = try { 262 | api.like( 263 | like = like, 264 | body = mapOf( 265 | "alg" to "itembased", 266 | "trackId" to musicId.toString(), 267 | "like" to like.toString(), 268 | "time" to "3" 269 | ) 270 | ) 271 | 272 | } catch (e: Exception) { 273 | e.printStackTrace() 274 | null 275 | } 276 | 277 | suspend fun manipulatePlaylist( 278 | playlistId: Long, 279 | op: String, 280 | trackIds: Set 281 | ): JsonObject? = try { 282 | op.requireOneOf("add", "del") 283 | 284 | api.manipulatePlaylist( 285 | mapOf( 286 | "op" to op, 287 | "pid" to playlistId.toString(), 288 | "trackIds" to trackIds.joinToString( 289 | separator = ",", 290 | prefix = "[", 291 | postfix = "]" 292 | ) { it.toString() }, 293 | "imme" to "true" 294 | ) 295 | ) 296 | } catch (e: Exception) { 297 | e.printStackTrace() 298 | null 299 | } 300 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/RouteActivity.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic 2 | 3 | import android.os.Build 4 | import android.os.Bundle 5 | import android.view.ViewGroup 6 | import androidx.activity.ComponentActivity 7 | import androidx.activity.compose.setContent 8 | import androidx.compose.animation.* 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.runtime.CompositionLocalProvider 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.setValue 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.platform.ComposeView 17 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 18 | import androidx.core.view.WindowCompat 19 | import androidx.lifecycle.lifecycleScope 20 | import androidx.navigation.NavType 21 | import androidx.navigation.navArgument 22 | import coil.ImageLoader 23 | import coil.compose.LocalImageLoader 24 | import com.google.accompanist.navigation.animation.AnimatedNavHost 25 | import com.google.accompanist.navigation.animation.composable 26 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController 27 | import dagger.hilt.android.AndroidEntryPoint 28 | import kotlinx.coroutines.flow.combine 29 | import kotlinx.coroutines.flow.launchIn 30 | import kotlinx.coroutines.flow.onCompletion 31 | import kotlinx.coroutines.flow.onEach 32 | import me.rerere.rainmusic.data.model.UserData 33 | import me.rerere.rainmusic.repo.UserRepo 34 | import me.rerere.rainmusic.ui.local.LocalNavController 35 | import me.rerere.rainmusic.ui.local.LocalUserData 36 | import me.rerere.rainmusic.ui.screen.Screen 37 | import me.rerere.rainmusic.ui.screen.dailysong.DailySongScreen 38 | import me.rerere.rainmusic.ui.screen.index.IndexScreen 39 | import me.rerere.rainmusic.ui.screen.login.LoginScreen 40 | import me.rerere.rainmusic.ui.screen.player.PlayerScreen 41 | import me.rerere.rainmusic.ui.screen.playlist.PlaylistScreen 42 | import me.rerere.rainmusic.ui.screen.search.SearchScreen 43 | import me.rerere.rainmusic.ui.screen.test.TestScreen 44 | import me.rerere.rainmusic.ui.theme.RainMusicTheme 45 | import me.rerere.rainmusic.util.DataState 46 | import me.rerere.rainmusic.util.defaultEnterTransition 47 | import me.rerere.rainmusic.util.defaultPopExitTransition 48 | import me.rerere.rainmusic.util.toast 49 | import okhttp3.OkHttpClient 50 | import javax.inject.Inject 51 | 52 | @AndroidEntryPoint 53 | class RouteActivity : ComponentActivity() { 54 | @Inject 55 | lateinit var userRepo: UserRepo 56 | 57 | @Inject 58 | lateinit var okHttpClient: OkHttpClient 59 | 60 | var preparingData = true 61 | var userData by mutableStateOf(UserData.VISITOR) 62 | 63 | override fun onCreate(savedInstanceState: Bundle?) { 64 | super.onCreate(savedInstanceState) 65 | 66 | // Edge to Edge 67 | WindowCompat.setDecorFitsSystemWindows(window, false) 68 | 69 | // 启动闪屏 70 | installSplashScreen().apply { 71 | // 准备完数据才结束splash screen 72 | setKeepOnScreenCondition { preparingData } 73 | } 74 | 75 | // splash screen时加载用户数据 76 | init() 77 | 78 | setContent { 79 | RainMusicTheme { 80 | val navController = rememberAnimatedNavController() 81 | 82 | CompositionLocalProvider( 83 | // 全局提供NavController 84 | LocalNavController provides navController, 85 | // 全局提供用户账号信息 86 | LocalUserData provides userData, 87 | // Coil 88 | LocalImageLoader provides ImageLoader.Builder(this) 89 | .okHttpClient(okHttpClient) 90 | .build() 91 | ) { 92 | AnimatedNavHost( 93 | modifier = Modifier.fillMaxSize(), 94 | navController = navController, 95 | startDestination = "index", 96 | enterTransition = defaultEnterTransition, 97 | exitTransition = { 98 | if (targetState.destination.route == Screen.Player.route) { 99 | fadeOut() 100 | } else { 101 | slideOutHorizontally( 102 | targetOffsetX = { 103 | -it 104 | }, 105 | animationSpec = tween() 106 | ) + fadeOut( 107 | animationSpec = tween() 108 | ) 109 | } 110 | }, 111 | popEnterTransition = { 112 | if (initialState.destination.route == Screen.Player.route) { 113 | fadeIn() 114 | } else { 115 | slideInHorizontally( 116 | initialOffsetX = { 117 | -it 118 | }, 119 | animationSpec = tween() 120 | ) 121 | } 122 | }, 123 | popExitTransition = defaultPopExitTransition 124 | ) { 125 | composable(Screen.Login.route) { 126 | LoginScreen() 127 | } 128 | 129 | composable(Screen.Index.route) { 130 | IndexScreen() 131 | } 132 | 133 | composable(Screen.Search.route) { 134 | SearchScreen() 135 | } 136 | 137 | composable( 138 | route = "${Screen.Playlist.route}/{id}", 139 | arguments = listOf( 140 | navArgument("id") { 141 | type = NavType.LongType 142 | } 143 | ) 144 | ) { 145 | PlaylistScreen(id = it.arguments!!.getLong("id")) 146 | } 147 | 148 | composable( 149 | route = Screen.Player.route, 150 | enterTransition = { 151 | slideInVertically( 152 | initialOffsetY = { 153 | it 154 | }, 155 | animationSpec = tween() 156 | ) + fadeIn() 157 | }, 158 | popExitTransition = { 159 | slideOutVertically( 160 | targetOffsetY = { 161 | it 162 | }, 163 | animationSpec = tween() 164 | ) + fadeOut() 165 | } 166 | ) { 167 | PlayerScreen() 168 | } 169 | 170 | composable(Screen.DailySong.route) { 171 | DailySongScreen() 172 | } 173 | 174 | // 测试各种API,Compose组件的Screen 175 | // 不在release版本中提供这个screen 176 | if (BuildConfig.DEBUG) { 177 | composable("test") { 178 | TestScreen() 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | 186 | // 禁止强制深色模式 187 | (window.decorView.findViewById(android.R.id.content) 188 | .getChildAt(0) as? ComposeView)?.let { 189 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 190 | it.isForceDarkAllowed = false 191 | } 192 | } 193 | } 194 | 195 | private fun init() { 196 | // 自动签到 197 | userRepo.dailySign().onEach { 198 | if (it is DataState.Success) { 199 | it.readSafely()?.code?.takeIf { code -> code == 200 }?.let { 200 | toast("自动签到成功!") 201 | } 202 | } 203 | }.launchIn(lifecycleScope) 204 | 205 | // 检查身份信息 206 | combine( 207 | userRepo.refreshLogin(), 208 | userRepo.getAccountDetail() 209 | ) { a, b -> 210 | a to b 211 | }.onEach { 212 | if (it.first is DataState.Error) { 213 | toast("未登录!") 214 | } 215 | 216 | if (it.second is DataState.Success) { 217 | val data = it.second.read() 218 | userData = UserData( 219 | id = data.account!!.id, 220 | nickname = data.profile!!.nickname, 221 | avatarUrl = data.profile.avatarUrl 222 | ) 223 | } 224 | }.onCompletion { 225 | preparingData = false 226 | }.launchIn(lifecycleScope) 227 | } 228 | 229 | fun retryInit() { 230 | init() 231 | } 232 | } -------------------------------------------------------------------------------- /app/src/main/java/me/rerere/rainmusic/ui/screen/index/IndexScreen.kt: -------------------------------------------------------------------------------- 1 | package me.rerere.rainmusic.ui.screen.index 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.ExperimentalAnimationApi 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.combinedClickable 8 | import androidx.compose.foundation.layout.* 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.rounded.* 12 | import androidx.compose.material3.* 13 | import androidx.compose.runtime.* 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.clip 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.platform.LocalContext 19 | import androidx.compose.ui.res.stringResource 20 | import androidx.compose.ui.unit.dp 21 | import androidx.hilt.navigation.compose.hiltViewModel 22 | import coil.compose.ImagePainter 23 | import coil.compose.rememberImagePainter 24 | import com.google.accompanist.pager.ExperimentalPagerApi 25 | import com.google.accompanist.pager.HorizontalPager 26 | import com.google.accompanist.pager.PagerState 27 | import com.google.accompanist.pager.rememberPagerState 28 | import kotlinx.coroutines.launch 29 | import me.rerere.rainmusic.BuildConfig 30 | import me.rerere.rainmusic.R 31 | import me.rerere.rainmusic.RouteActivity 32 | import me.rerere.rainmusic.ui.component.* 33 | import me.rerere.rainmusic.ui.local.LocalNavController 34 | import me.rerere.rainmusic.ui.local.LocalUserData 35 | import me.rerere.rainmusic.ui.screen.Screen 36 | import me.rerere.rainmusic.ui.screen.index.page.DiscoverPage 37 | import me.rerere.rainmusic.ui.screen.index.page.IndexPage 38 | import me.rerere.rainmusic.ui.screen.index.page.LibraryPage 39 | import me.rerere.rainmusic.util.DataState 40 | import me.rerere.rainmusic.util.okhttp.CookieHelper 41 | 42 | @OptIn(ExperimentalFoundationApi::class) 43 | @ExperimentalAnimationApi 44 | @ExperimentalPagerApi 45 | @ExperimentalMaterial3Api 46 | @Composable 47 | fun IndexScreen( 48 | indexViewModel: IndexViewModel = hiltViewModel() 49 | ) { 50 | val navController = LocalNavController.current 51 | val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() 52 | val pagerState = rememberPagerState() 53 | val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) 54 | ModalNavigationDrawer( 55 | drawerContent = { 56 | DrawerContent(indexViewModel) 57 | }, 58 | drawerState = drawerState 59 | ) { 60 | Scaffold( 61 | topBar = { 62 | IndexTopBar( 63 | indexViewModel = indexViewModel, 64 | scrollBehavior = scrollBehavior, 65 | drawerState = drawerState 66 | ) 67 | }, 68 | bottomBar = { 69 | BottomNavigationBar( 70 | pagerState = pagerState 71 | ) 72 | }, 73 | floatingActionButton = { 74 | FloatingActionButton(onClick = { 75 | Screen.Player.navigate(navController) 76 | }) { 77 | Icon(Icons.Rounded.QueueMusic, null) 78 | } 79 | } 80 | ) { 81 | Column { 82 | NetworkBanner(indexViewModel) 83 | 84 | HorizontalPager( 85 | modifier = Modifier 86 | .fillMaxSize() 87 | .padding(it), 88 | count = 3, 89 | state = pagerState, 90 | ) { page -> 91 | when (page) { 92 | 0 -> { 93 | IndexPage(indexViewModel) 94 | } 95 | 1 -> { 96 | DiscoverPage(indexViewModel) 97 | } 98 | 2 -> { 99 | RequireLoginVisible { 100 | LibraryPage(indexViewModel) 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | @Composable 111 | private fun NetworkBanner( 112 | indexViewModel: IndexViewModel 113 | ) { 114 | val context = LocalContext.current 115 | val data by indexViewModel.personalizedSongs.collectAsState() 116 | AnimatedVisibility( 117 | visible = data is DataState.Error 118 | ) { 119 | NetworkIssueBanner { 120 | (context as RouteActivity).retryInit() 121 | indexViewModel.refreshIndexPage() 122 | } 123 | } 124 | } 125 | 126 | @ExperimentalFoundationApi 127 | @ExperimentalAnimationApi 128 | @ExperimentalMaterial3Api 129 | @Composable 130 | private fun IndexTopBar( 131 | indexViewModel: IndexViewModel, 132 | scrollBehavior: TopAppBarScrollBehavior, 133 | drawerState: DrawerState 134 | ) { 135 | val navController = LocalNavController.current 136 | val scope = rememberCoroutineScope() 137 | val accountData = LocalUserData.current 138 | RainTopBar( 139 | navigationIcon = { 140 | val avatarPainter = rememberImagePainter( 141 | data = if (!accountData.isVisitor) 142 | accountData.avatarUrl 143 | else 144 | R.drawable.netease_music 145 | ) 146 | IconButton(onClick = { 147 | scope.launch { 148 | drawerState.open() 149 | } 150 | }) { 151 | Icon( 152 | modifier = Modifier 153 | .clip(CircleShape) 154 | .shimmerPlaceholder(avatarPainter.state is ImagePainter.State.Loading), 155 | painter = avatarPainter, 156 | contentDescription = "avatar", 157 | tint = Color.Unspecified 158 | ) 159 | } 160 | }, 161 | title = { 162 | if (!accountData.isVisitor) { 163 | Text(text = accountData.nickname) 164 | } else { 165 | Text(text = stringResource(R.string.app_name)) 166 | } 167 | }, 168 | actions = { 169 | var showDebugButtons by remember { 170 | mutableStateOf(false) 171 | } 172 | if (BuildConfig.DEBUG && showDebugButtons) { 173 | TextButton(onClick = { 174 | CookieHelper.logout() 175 | }) { 176 | Text(text = "注销登录") 177 | } 178 | TextButton(onClick = { 179 | Screen.Test.navigate(navController) 180 | }) { 181 | Text(text = "测试页面") 182 | } 183 | } 184 | 185 | Icon( 186 | modifier = Modifier 187 | .combinedClickable( 188 | onClick = { 189 | navController.navigate(Screen.Search.route) 190 | }, 191 | onLongClick = { 192 | showDebugButtons = !showDebugButtons 193 | } 194 | ) 195 | .padding(8.dp), 196 | imageVector = Icons.Rounded.Search, 197 | contentDescription = "Search" 198 | ) 199 | }, 200 | appBarStyle = AppBarStyle.Small, 201 | scrollBehavior = scrollBehavior 202 | ) 203 | } 204 | 205 | @ExperimentalPagerApi 206 | @Composable 207 | private fun BottomNavigationBar( 208 | pagerState: PagerState 209 | ) { 210 | val scope = rememberCoroutineScope() 211 | RainBottomNavigation { 212 | NavigationBarItem( 213 | selected = pagerState.currentPage == 0, 214 | onClick = { 215 | scope.launch { 216 | pagerState.animateScrollToPage( 217 | page = 0 218 | ) 219 | } 220 | }, 221 | icon = { 222 | Icon(Icons.Rounded.Recommend, null) 223 | }, 224 | label = { 225 | Text(text = "推荐") 226 | } 227 | ) 228 | 229 | NavigationBarItem( 230 | selected = pagerState.currentPage == 1, 231 | onClick = { 232 | scope.launch { 233 | pagerState.animateScrollToPage( 234 | page = 1 235 | ) 236 | } 237 | }, 238 | icon = { 239 | Icon(Icons.Rounded.FeaturedPlayList, null) 240 | }, 241 | label = { 242 | Text(text = "发现") 243 | } 244 | ) 245 | 246 | NavigationBarItem( 247 | selected = pagerState.currentPage == 2, 248 | onClick = { 249 | scope.launch { 250 | pagerState.animateScrollToPage( 251 | page = 2 252 | ) 253 | } 254 | }, 255 | icon = { 256 | Icon(Icons.Rounded.Headphones, null) 257 | }, 258 | label = { 259 | Text(text = "音乐库") 260 | } 261 | ) 262 | } 263 | } 264 | 265 | @Composable 266 | private fun DrawerContent(indexViewModel: IndexViewModel) { 267 | val userData = LocalUserData.current 268 | Column( 269 | modifier = Modifier 270 | .statusBarsPadding() 271 | .navigationBarsPadding(), 272 | verticalArrangement = Arrangement.spacedBy(8.dp) 273 | ) { 274 | // TODO: 完善drawer 275 | Row( 276 | verticalAlignment = Alignment.CenterVertically 277 | ) { 278 | Image( 279 | painter = rememberImagePainter(data = userData.avatarUrl), 280 | contentDescription = null, 281 | modifier = Modifier.clip(CircleShape) 282 | ) 283 | } 284 | } 285 | } --------------------------------------------------------------------------------