diff --git a/AAmusic/.gitignore b/AAmusic/.gitignore new file mode 100644 index 0000000..5f94008 --- /dev/null +++ b/AAmusic/.gitignore @@ -0,0 +1,7 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures diff --git a/AAmusic/README.md b/AAmusic/README.md new file mode 100644 index 0000000..e69de29 diff --git a/AAmusic/app/.gitignore b/AAmusic/app/.gitignore new file mode 100644 index 0000000..a2def88 --- /dev/null +++ b/AAmusic/app/.gitignore @@ -0,0 +1,2 @@ +/build +google-services.json diff --git a/AAmusic/app/build.gradle.kts b/AAmusic/app/build.gradle.kts new file mode 100644 index 0000000..b7a8d72 --- /dev/null +++ b/AAmusic/app/build.gradle.kts @@ -0,0 +1,172 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + if (File("app/google-services.json").exists()) { + println("enable gms in app plugins") + alias(libs.plugins.gms) + alias(libs.plugins.crashlytics) + } + id("kotlin-kapt") + id("kotlin-parcelize") + id("auto-register") +} + +android { + namespace = "me.wcy.music" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "me.wcy.music" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = libs.versions.versionCode.get().toInt() + versionName = libs.versions.versionName.get() + + multiDexEnabled = true + + ndk { + abiFilters.apply { + add("armeabi-v7a") + add("arm64-v8a") + add("x86_64") + } + } + + applicationVariants.all { + outputs.all { + if (this is com.android.build.gradle.internal.api.ApkVariantOutputImpl) { + this.outputFileName = "AAmusic.apk" + } + } + } + } + + buildFeatures { + viewBinding = true + } + + signingConfigs { + register("release") { + enableV1Signing = true + enableV2Signing = true + storeFile = file("wangchenyan.keystore") + storePassword = getLocalValue("STORE_PASSWORD") + keyAlias = getLocalValue("KEY_ALIAS") + keyPassword = getLocalValue("KEY_PASSWORD") + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + signingConfig = signingConfigs.getByName("release") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.valueOf(libs.versions.java.get()) + targetCompatibility = JavaVersion.valueOf(libs.versions.java.get()) + } +} + +fun getLocalValue(key: String): String { + return getLocalValue(key, false) +} + +fun getLocalValue(key: String, quot: Boolean): String { + val properties = Properties() + properties.load(project.rootProject.file("local.properties").inputStream()) + var value = if (properties.containsKey(key)) { + properties[key].toString() + } else { + "" + } + if (quot) { + value = "\"" + value + "\"" + } + return value +} + +kapt { + // For hilt: Allow references to generated code + correctErrorTypes = true +} + +ksp { + arg("moduleName", project.name) + // crouter 默认 scheme + arg("defaultScheme", "app") + // crouter 默认 host + arg("defaultHost", "music") + arg("room.schemaLocation", "$projectDir/schemas") +} + +autoregister { + registerInfo = listOf( + // crouter 注解收集 + mapOf( + "scanInterface" to "me.wcy.router.annotation.RouteLoader", + "codeInsertToClassName" to "me.wcy.router.RouteSet", + "codeInsertToMethodName" to "init", + "registerMethodName" to "register", + "include" to listOf("me/wcy/router/annotation/loader/.*") + ) + ) +} + +dependencies { + implementation(libs.appcompat) + implementation(libs.constraintlayout) + implementation(libs.material) + implementation(libs.media3.exoplayer) + implementation(libs.media3.datasource.okhttp) + implementation(libs.media3.session) + implementation(libs.media3.ui) + implementation(libs.preference) + implementation(libs.flexbox) + implementation(libs.firebase.crashlytics.buildtools) + implementation(libs.support.annotations) + implementation(libs.annotation) + implementation(libs.support.v4) + implementation(libs.legacy.support.v4) + implementation(libs.support.v13) + implementation(libs.legacy.support.v13) + implementation(libs.appcompat.v7) + implementation(libs.support.vector.drawable) + implementation(libs.vectordrawable) + implementation(libs.design) + implementation(libs.gridlayout.v7) + implementation(libs.gridlayout) + implementation(libs.kotlin.reflect) + implementation(libs.error.prone.annotations) + + ksp(libs.room.compiler) + implementation(libs.room) + kapt(libs.hilt.compiler) + implementation(libs.hilt) + + if (File("${project.projectDir}/google-services.json").exists()) { + println("enable gms in app dependencies") + implementation(libs.crashlytics) + implementation(libs.analytics) + } + + implementation(libs.common) + ksp(libs.crouter.compiler) + implementation(libs.crouter.api) + implementation(libs.lrcview) + + implementation(libs.loggingInterceptor) + implementation(libs.zbar) + implementation(libs.blurry) + implementation(libs.banner) +} diff --git a/AAmusic/app/proguard-rules.pro b/AAmusic/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/AAmusic/app/release/AAmusic.apk b/AAmusic/app/release/AAmusic.apk new file mode 100644 index 0000000..225e409 Binary files /dev/null and b/AAmusic/app/release/AAmusic.apk differ diff --git a/AAmusic/app/release/output-metadata.json b/AAmusic/app/release/output-metadata.json new file mode 100644 index 0000000..73e0c4b --- /dev/null +++ b/AAmusic/app/release/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "me.wcy.music", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 2030001, + "versionName": "2.3.0-beta01", + "outputFile": "AAmusic.apk" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/AAmusic/app/schemas/me.wcy.music.storage.db.MusicDatabase/1.json b/AAmusic/app/schemas/me.wcy.music.storage.db.MusicDatabase/1.json new file mode 100644 index 0000000..cd2add2 --- /dev/null +++ b/AAmusic/app/schemas/me.wcy.music.storage.db.MusicDatabase/1.json @@ -0,0 +1,134 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "4ee3f3ec308e5eb7aa82deecaf89bd50", + "entities": [ + { + "tableName": "play_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `song_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `artist_id` INTEGER NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `album_cover` TEXT NOT NULL, `duration` INTEGER NOT NULL, `path` TEXT NOT NULL, `file_name` TEXT NOT NULL, `file_size` INTEGER NOT NULL, `unique_id` TEXT NOT NULL, PRIMARY KEY(`unique_id`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "albumCover", + "columnName": "album_cover", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uniqueId", + "columnName": "unique_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "unique_id" + ] + }, + "indices": [ + { + "name": "index_play_list_title", + "unique": false, + "columnNames": [ + "title" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_list_title` ON `${TABLE_NAME}` (`title`)" + }, + { + "name": "index_play_list_artist", + "unique": false, + "columnNames": [ + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_list_artist` ON `${TABLE_NAME}` (`artist`)" + }, + { + "name": "index_play_list_album", + "unique": false, + "columnNames": [ + "album" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_list_album` ON `${TABLE_NAME}` (`album`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4ee3f3ec308e5eb7aa82deecaf89bd50')" + ] + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/AndroidManifest.xml b/AAmusic/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0df3f6e --- /dev/null +++ b/AAmusic/app/src/main/AndroidManifest.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/ic_launcher-playstore.png b/AAmusic/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..ef6ee25 Binary files /dev/null and b/AAmusic/app/src/main/ic_launcher-playstore.png differ diff --git a/AAmusic/app/src/main/java/me/wcy/music/MusicApplication.kt b/AAmusic/app/src/main/java/me/wcy/music/MusicApplication.kt new file mode 100644 index 0000000..97d852f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/MusicApplication.kt @@ -0,0 +1,94 @@ +package me.wcy.music + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.blankj.utilcode.util.ActivityUtils +import com.google.common.util.concurrent.MoreExecutors +import dagger.hilt.android.HiltAndroidApp +import me.wcy.music.account.service.UserService +import me.wcy.music.common.DarkModeService +import me.wcy.music.common.MusicFragmentContainerActivity +import me.wcy.music.service.MusicService +import me.wcy.music.service.PlayServiceModule +import me.wcy.music.service.likesong.LikeSongProcessor +import me.wcy.router.CRouter +import me.wcy.router.RouterClient +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.ext.findActivity +import javax.inject.Inject + +/** + * 自定义Application + * Created by wcy on 2015/11/27. + */ +@HiltAndroidApp +class MusicApplication : Application() { + @Inject + lateinit var userService: UserService + + @Inject + lateinit var darkModeService: DarkModeService + + @Inject + lateinit var likeSongProcessor: LikeSongProcessor + + override fun onCreate() { + super.onCreate() + + CommonApp.init { + test = BuildConfig.DEBUG + isDarkMode = { darkModeService.isDarkMode() } + titleLayoutConfig { + isStatusBarDarkFontWhenAuto = { darkModeService.isDarkMode().not() } + textColorAuto = { R.color.common_text_h1_color } + textColorBlack = { R.color.common_text_h1_color } + isTitleCenter = false + } + imageLoaderConfig { + placeholderAvatar = R.drawable.ic_launcher_round + } + apiConfig({}) { + codeJsonNames = listOf("code") + msgJsonNames = listOf("message", "msg") + dataJsonNames = listOf("data", "result") + successCode = 200 + } + } + initCRouter() + darkModeService.init() + likeSongProcessor.init() + + val sessionToken = + SessionToken(this, ComponentName(this, MusicService::class.java)) + val mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + mediaControllerFuture.addListener({ + val player = mediaControllerFuture.get() + PlayServiceModule.setPlayer(player) + }, MoreExecutors.directExecutor()) + } + + private fun initCRouter() { + CRouter.setRouterClient( + RouterClient.Builder() + .baseUrl("app://music") + .loginProvider { context, callback -> + var activity = context.findActivity() + if (activity == null) { + activity = ActivityUtils.getTopActivity() + } + if (activity != null) { + userService.checkLogin(activity) { + callback() + } + } + } + .fragmentContainerIntentProvider { + Intent(it, MusicFragmentContainerActivity::class.java) + } + .build() + ) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/AccountApi.kt b/AAmusic/app/src/main/java/me/wcy/music/account/AccountApi.kt new file mode 100644 index 0000000..0611215 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/AccountApi.kt @@ -0,0 +1,72 @@ +package me.wcy.music.account + +import top.wangchenyan.common.net.NetResult +import top.wangchenyan.common.net.gson.GsonConverterFactory +import top.wangchenyan.common.utils.GsonUtils +import top.wangchenyan.common.utils.ServerTime +import me.wcy.music.account.bean.LoginResultData +import me.wcy.music.account.bean.LoginStatusData +import me.wcy.music.account.bean.QrCodeData +import me.wcy.music.account.bean.QrCodeKeyData +import me.wcy.music.account.bean.SendCodeResult +import me.wcy.music.net.HttpClient +import me.wcy.music.storage.preference.ConfigPreferences +import retrofit2.Retrofit +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * + */ +interface AccountApi { + + @GET("captcha/sent") + suspend fun sendPhoneCode( + @Query("phone") phone: String, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): SendCodeResult + + @GET("login/cellphone") + suspend fun phoneLogin( + @Query("phone") phone: String, + @Query("captcha") captcha: String, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): LoginResultData + + @GET("login/qr/key") + suspend fun getQrCodeKey( + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): NetResult + + @GET("login/qr/create") + suspend fun getLoginQrCode( + @Query("key") key: String, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): NetResult + + @GET("login/qr/check") + suspend fun checkLoginStatus( + @Query("key") key: String, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis(), + @Query("noCookie") noCookie: Boolean = true + ): LoginResultData + + @POST("login/status") + suspend fun getLoginStatus( + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): LoginStatusData + + companion object { + private val api: AccountApi by lazy { + val retrofit = Retrofit.Builder() + .baseUrl(ConfigPreferences.apiDomain) + .addConverterFactory(GsonConverterFactory.create(GsonUtils.gson, true)) + .client(HttpClient.okHttpClient) + .build() + retrofit.create(AccountApi::class.java) + } + + fun get(): AccountApi = api + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/AccountPreference.kt b/AAmusic/app/src/main/java/me/wcy/music/account/AccountPreference.kt new file mode 100644 index 0000000..b5c032c --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/AccountPreference.kt @@ -0,0 +1,16 @@ +package me.wcy.music.account + +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.storage.IPreferencesFile +import top.wangchenyan.common.storage.PreferencesFile +import me.wcy.music.account.bean.ProfileData +import me.wcy.music.consts.PreferenceName + +/** + * + */ +object AccountPreference : + IPreferencesFile by PreferencesFile(CommonApp.app, PreferenceName.ACCOUNT, false) { + var cookie by IPreferencesFile.StringProperty("cookie", "") + var profile by IPreferencesFile.ObjectProperty("profile", ProfileData::class.java) +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/bean/LoginResultData.kt b/AAmusic/app/src/main/java/me/wcy/music/account/bean/LoginResultData.kt new file mode 100644 index 0000000..88b48b8 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/bean/LoginResultData.kt @@ -0,0 +1,30 @@ +package me.wcy.music.account.bean + +import com.google.gson.annotations.SerializedName + +data class LoginResultData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("message") + val message: String = "", + @SerializedName("nickname") + val nickname: String = "", + @SerializedName("avatarUrl") + val avatarUrl: String = "", + @SerializedName("cookie") + val cookie: String = "" +) { + companion object { + // 二维码不存在或已过期 + const val STATUS_INVALID = 800 + + // 等待扫码 + const val STATUS_NOT_SCAN = 801 + + // 授权中 + const val STATUS_SCANNING = 802 + + // 授权登陆成功,包含 cookie + const val STATUS_SUCCESS = 803 + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/bean/LoginStatusData.kt b/AAmusic/app/src/main/java/me/wcy/music/account/bean/LoginStatusData.kt new file mode 100644 index 0000000..d75c98c --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/bean/LoginStatusData.kt @@ -0,0 +1,46 @@ +package me.wcy.music.account.bean + +import com.google.gson.annotations.SerializedName + +data class LoginStatusData( + @SerializedName("data") + val `data`: Data = Data() +) { + data class Data( + @SerializedName("code") + val code: Int = 0, + @SerializedName("account") + val account: Account = Account(), + @SerializedName("profile") + val profile: ProfileData? = null + ) { + data class Account( + @SerializedName("id") + val id: Int = 0, + @SerializedName("userName") + val userName: String = "", + @SerializedName("type") + val type: Int = 0, + @SerializedName("status") + val status: Int = 0, + @SerializedName("whitelistAuthority") + val whitelistAuthority: Int = 0, + @SerializedName("createTime") + val createTime: Long = 0, + @SerializedName("tokenVersion") + val tokenVersion: Int = 0, + @SerializedName("ban") + val ban: Int = 0, + @SerializedName("baoyueVersion") + val baoyueVersion: Int = 0, + @SerializedName("donateVersion") + val donateVersion: Int = 0, + @SerializedName("vipType") + val vipType: Int = 0, + @SerializedName("anonimousUser") + val anonimousUser: Boolean = false, + @SerializedName("paidFee") + val paidFee: Boolean = false + ) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/bean/ProfileData.kt b/AAmusic/app/src/main/java/me/wcy/music/account/bean/ProfileData.kt new file mode 100644 index 0000000..0801cb9 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/bean/ProfileData.kt @@ -0,0 +1,71 @@ +package me.wcy.music.account.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class ProfileData( + @SerializedName("userId") + val userId: Long = 0, + @SerializedName("userType") + val userType: Int = 0, + @SerializedName("nickname") + val nickname: String = "", + @SerializedName("avatarImgId") + val avatarImgId: Long = 0, + @SerializedName("avatarUrl") + val avatarUrl: String = "", + @SerializedName("backgroundImgId") + val backgroundImgId: Long = 0, + @SerializedName("backgroundUrl") + val backgroundUrl: String = "", + @SerializedName("signature") + val signature: String = "", + @SerializedName("createTime") + val createTime: Long = 0, + @SerializedName("userName") + val userName: String = "", + @SerializedName("accountType") + val accountType: Int = 0, + @SerializedName("shortUserName") + val shortUserName: String = "", + @SerializedName("birthday") + val birthday: Long = 0, + @SerializedName("authority") + val authority: Int = 0, + @SerializedName("gender") + val gender: Int = 0, + @SerializedName("accountStatus") + val accountStatus: Int = 0, + @SerializedName("province") + val province: Int = 0, + @SerializedName("city") + val city: Int = 0, + @SerializedName("authStatus") + val authStatus: Int = 0, + @SerializedName("defaultAvatar") + val defaultAvatar: Boolean = false, + @SerializedName("djStatus") + val djStatus: Int = 0, + @SerializedName("locationStatus") + val locationStatus: Int = 0, + @SerializedName("vipType") + val vipType: Int = 0, + @SerializedName("followed") + val followed: Boolean = false, + @SerializedName("mutual") + val mutual: Boolean = false, + @SerializedName("authenticated") + val authenticated: Boolean = false, + @SerializedName("lastLoginTime") + val lastLoginTime: Long = 0, + @SerializedName("lastLoginIP") + val lastLoginIP: String = "", + @SerializedName("viptypeVersion") + val viptypeVersion: Long = 0, + @SerializedName("authenticationTypes") + val authenticationTypes: Int = 0, + @SerializedName("anchor") + val anchor: Boolean = false +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/bean/QrCodeData.kt b/AAmusic/app/src/main/java/me/wcy/music/account/bean/QrCodeData.kt new file mode 100644 index 0000000..6cd90e9 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/bean/QrCodeData.kt @@ -0,0 +1,8 @@ +package me.wcy.music.account.bean + +import com.google.gson.annotations.SerializedName + +data class QrCodeData( + @SerializedName("qrurl") + val qrurl: String = "" +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/bean/QrCodeKeyData.kt b/AAmusic/app/src/main/java/me/wcy/music/account/bean/QrCodeKeyData.kt new file mode 100644 index 0000000..5a6847e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/bean/QrCodeKeyData.kt @@ -0,0 +1,10 @@ +package me.wcy.music.account.bean + +import com.google.gson.annotations.SerializedName + +data class QrCodeKeyData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("unikey") + val unikey: String = "" +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/bean/SendCodeResult.kt b/AAmusic/app/src/main/java/me/wcy/music/account/bean/SendCodeResult.kt new file mode 100644 index 0000000..7a92d50 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/bean/SendCodeResult.kt @@ -0,0 +1,10 @@ +package me.wcy.music.account.bean + +import com.google.gson.annotations.SerializedName + +data class SendCodeResult( + @SerializedName("code") + val code: Int = 0, + @SerializedName("message") + val message: String = "", +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/login/LoginRouteFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/account/login/LoginRouteFragment.kt new file mode 100644 index 0000000..3e888ac --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/login/LoginRouteFragment.kt @@ -0,0 +1,60 @@ +package me.wcy.music.account.login + +import android.view.View +import top.wangchenyan.common.ext.viewBindings +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentLoginRouteBinding +import me.wcy.router.CRouter +import me.wcy.router.RouteResultListener +import me.wcy.router.annotation.Route + +/** + * + */ +@Route(RoutePath.LOGIN) +class LoginRouteFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isLazy(): Boolean { + return false + } + + private val routeResultListener: RouteResultListener = { + if (it.isSuccess()) { + setResultAndFinish() + } else if (it.resultCode == RESULT_SWITCH_QRCODE) { + startQrCode() + } else if (it.resultCode == RESULT_SWITCH_PHONE) { + startPhone() + } else { + finish() + } + } + + override fun onLazyCreate() { + super.onLazyCreate() + startPhone() + } + + private fun startPhone() { + CRouter.with(requireActivity()) + .url(RoutePath.PHONE_LOGIN) + .startForResult(routeResultListener) + } + + private fun startQrCode() { + CRouter.with(requireActivity()) + .url(RoutePath.QRCODE_LOGIN) + .startForResult(routeResultListener) + } + + companion object { + const val RESULT_SWITCH_QRCODE = 100 + const val RESULT_SWITCH_PHONE = 200 + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/login/phone/PhoneLoginFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/account/login/phone/PhoneLoginFragment.kt new file mode 100644 index 0000000..c34fefe --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/login/phone/PhoneLoginFragment.kt @@ -0,0 +1,115 @@ +package me.wcy.music.account.login.phone + +import android.view.View +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.utils.ToastUtils +import me.wcy.music.account.login.LoginRouteFragment +import me.wcy.music.account.service.UserService +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentPhoneLoginBinding +import me.wcy.router.annotation.Route +import javax.inject.Inject + +/** + * + */ +@Route(RoutePath.PHONE_LOGIN) +@AndroidEntryPoint +class PhoneLoginFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + + @Inject + lateinit var userService: UserService + + override fun getRootView(): View { + return viewBinding.root + } + + override fun onLazyCreate() { + super.onLazyCreate() + + initView() + initDataObserver() + } + + private fun initView() { + val updateLoginBtnState = { + viewBinding.btnLogin.isEnabled = + viewBinding.etPhone.length() > 0 && viewBinding.etPhoneCode.length() > 0 + } + viewBinding.etPhone.doAfterTextChanged { + updateLoginBtnState() + } + viewBinding.etPhoneCode.doAfterTextChanged { + updateLoginBtnState() + } + viewBinding.tvSendCode.setOnClickListener { + val phone = viewBinding.etPhone.text?.toString() + if (phone.isNullOrEmpty()) { + ToastUtils.show("请输入手机号") + return@setOnClickListener + } + lifecycleScope.launch { + viewBinding.tvSendCode.isEnabled = false + val res = viewModel.sendPhoneCode(phone) + if (res.isSuccess().not()) { + viewBinding.tvSendCode.isEnabled = true + ToastUtils.show(res.msg) + } + } + } + viewBinding.btnLogin.setOnClickListener { + val phone = viewBinding.etPhone.text?.toString() + if (phone.isNullOrEmpty()) { + ToastUtils.show("请输入手机号") + return@setOnClickListener + } + val code = viewBinding.etPhoneCode.text?.toString() + if (code.isNullOrEmpty()) { + ToastUtils.show("请输入手机验证码") + return@setOnClickListener + } + lifecycleScope.launch { + showLoading(false) + val res = viewModel.phoneLogin(phone, code) + dismissLoading() + if (res.isSuccess()) { + ToastUtils.show("登录成功") + setResultAndFinish() + } else { + ToastUtils.show(res.msg.orEmpty().ifEmpty { + "登录失败,请更新服务端版本或稍后重试" + }) + } + } + } + viewBinding.tvQrcodeLogin.setOnClickListener { + activity?.apply { + setResult(LoginRouteFragment.RESULT_SWITCH_QRCODE) + finish() + } + } + } + + private fun initDataObserver() { + lifecycleScope.launch { + viewModel.sendPhoneCodeCountdown.collectLatest { sendPhoneCodeCountdown -> + if (sendPhoneCodeCountdown > 0) { + viewBinding.tvSendCode.isEnabled = false + viewBinding.tvSendCode.text = "${sendPhoneCodeCountdown}秒后重发" + } else { + viewBinding.tvSendCode.isEnabled = true + viewBinding.tvSendCode.text = "获取验证码" + } + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/login/phone/PhoneLoginViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/account/login/phone/PhoneLoginViewModel.kt new file mode 100644 index 0000000..382e3fe --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/login/phone/PhoneLoginViewModel.kt @@ -0,0 +1,79 @@ +package me.wcy.music.account.login.phone + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.model.CommonResult +import me.wcy.music.account.AccountApi +import me.wcy.music.account.service.UserService +import me.wcy.music.net.NetUtils +import javax.inject.Inject + +/** + * + */ +@HiltViewModel +class PhoneLoginViewModel @Inject constructor( + private val userService: UserService +) : ViewModel() { + private val _sendPhoneCodeCountdown = MutableStateFlow(0) + val sendPhoneCodeCountdown = _sendPhoneCodeCountdown.toUnMutable() + + suspend fun sendPhoneCode(phone: String): CommonResult { + if (_sendPhoneCodeCountdown.value > 0) { + return CommonResult.fail() + } + val res = kotlin.runCatching { + AccountApi.get().sendPhoneCode(phone) + } + return if (res.isSuccess) { + val data = res.getOrThrow() + if (data.code == 200) { + viewModelScope.launch { + _sendPhoneCodeCountdown.value = 30 + repeat(Int.MAX_VALUE) { + delay(1000) + _sendPhoneCodeCountdown.value = _sendPhoneCodeCountdown.value - 1 + if (_sendPhoneCodeCountdown.value == 0) { + return@launch + } + } + } + CommonResult.success(Unit) + } else { + CommonResult.fail(data.code, data.message) + } + } else { + NetUtils.parseErrorResponse(res.exceptionOrNull()) + } + } + + suspend fun phoneLogin(phone: String, code: String): CommonResult { + val loginRes = kotlin.runCatching { + AccountApi.get().phoneLogin(phone, code) + } + return if (loginRes.isSuccess) { + val data = loginRes.getOrNull() + if (data?.code == 200) { + val getProfileRes = userService.login(data.cookie) + if (getProfileRes.isSuccessWithData()) { + CommonResult.success(Unit) + } else { + CommonResult.fail(getProfileRes.code, getProfileRes.msg) + } + } else { + CommonResult.fail(data?.code ?: -1, data?.message) + } + } else { + var result = NetUtils.parseErrorResponse(loginRes.exceptionOrNull()) + if (result.code == -462) { + result = result.copy(msg = "登录失败,请更新服务端版本或稍后重试") + } + return result + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/login/qrcode/QrcodeLoginFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/account/login/qrcode/QrcodeLoginFragment.kt new file mode 100644 index 0000000..791d984 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/login/qrcode/QrcodeLoginFragment.kt @@ -0,0 +1,119 @@ +package me.wcy.music.account.login.qrcode + +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.viewBindings +import me.wcy.music.account.bean.LoginResultData +import me.wcy.music.account.login.LoginRouteFragment +import me.wcy.music.account.service.UserService +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentQrcodeLoginBinding +import me.wcy.router.annotation.Route +import javax.inject.Inject + +/** + * + */ +@Route(RoutePath.QRCODE_LOGIN) +@AndroidEntryPoint +class QrcodeLoginFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + + @Inject + lateinit var userService: UserService + + override fun getRootView(): View { + return viewBinding.root + } + + override fun onLazyCreate() { + super.onLazyCreate() + + viewBinding.tvPhoneLogin.setOnClickListener { + activity?.apply { + setResult(LoginRouteFragment.RESULT_SWITCH_PHONE) + finish() + } + } + + loadQrCode() + + lifecycleScope.launch { + viewModel.loginStatus.collectLatest { status -> + viewBinding.tvStatus.setOnClickListener(null) + if (status == null) { + viewBinding.tvStatus.isVisible = true + viewBinding.tvStatus.text = "加载中…" + } else { + when (status.code) { + LoginResultData.STATUS_NOT_SCAN -> { + viewBinding.tvStatus.isVisible = false + } + + LoginResultData.STATUS_SCANNING -> { + viewBinding.tvStatus.isVisible = true + viewBinding.tvStatus.text = "「${status.nickname}」授权中" + } + + LoginResultData.STATUS_SUCCESS -> { + viewBinding.tvStatus.isVisible = true + viewBinding.tvStatus.text = status.message + getProfile(status.cookie) + } + + LoginResultData.STATUS_INVALID -> { + viewBinding.tvStatus.isVisible = true + viewBinding.tvStatus.text = "二维码已失效,点击刷新" + viewBinding.tvStatus.setOnClickListener { + loadQrCode() + } + } + + else -> { + viewBinding.tvStatus.isVisible = true + viewBinding.tvStatus.text = + status.message.ifEmpty { "二维码错误,点击刷新" } + viewBinding.tvStatus.setOnClickListener { + loadQrCode() + } + } + } + } + } + } + + lifecycleScope.launch { + viewModel.qrCode.collectLatest { qrCode -> + viewBinding.ivQrCode.setImageBitmap(qrCode) + } + } + } + + private fun loadQrCode() { + lifecycleScope.launch { + viewModel.getLoginQrCode() + } + } + + private fun getProfile(cookie: String) { + lifecycleScope.launch { + val res = userService.login(cookie) + if (res.isSuccessWithData()) { + setResultAndFinish() + } else { + viewBinding.tvStatus.isVisible = true + viewBinding.tvStatus.text = "登录失败,点击重试" + viewBinding.tvStatus.setOnClickListener { + loadQrCode() + } + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/login/qrcode/QrcodeLoginViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/account/login/qrcode/QrcodeLoginViewModel.kt new file mode 100644 index 0000000..2bfccb8 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/login/qrcode/QrcodeLoginViewModel.kt @@ -0,0 +1,78 @@ +package me.wcy.music.account.login.qrcode + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import cn.bertsir.zbar.utils.QRUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.net.apiCall +import me.wcy.music.account.AccountApi +import me.wcy.music.account.bean.LoginResultData +import me.wcy.music.account.service.UserService +import javax.inject.Inject + +/** + * + */ +@HiltViewModel +class QrcodeLoginViewModel @Inject constructor( + private val userService: UserService +) : ViewModel() { + private var qrCodeKey = "" + private val _qrCode = MutableStateFlow(null) + val qrCode = _qrCode + private val _loginStatus = MutableStateFlow(null) + val loginStatus = _loginStatus.toUnMutable() + private var job: Job? = null + + fun getLoginQrCode() { + job?.cancel() + job = viewModelScope.launch(Dispatchers.Default) { + qrCodeKey = "" + _qrCode.value = null + _loginStatus.value = null + val getKeyRes = apiCall { + AccountApi.get().getQrCodeKey() + } + if (getKeyRes.isSuccessWithData().not()) { + _loginStatus.value = LoginResultData(-1) + return@launch + } + val keyData = getKeyRes.getDataOrThrow() + qrCodeKey = keyData.unikey + val getQrCodeRes = apiCall { + AccountApi.get().getLoginQrCode(qrCodeKey) + } + if (getQrCodeRes.isSuccessWithData().not()) { + _loginStatus.value = LoginResultData(-1) + return@launch + } + val qrCodeData = getQrCodeRes.getDataOrThrow() + _qrCode.value = QRUtils.getInstance().createQRCode(qrCodeData.qrurl) + + while (true) { + kotlin.runCatching { + AccountApi.get().checkLoginStatus(qrCodeKey) + }.onSuccess { status -> + _loginStatus.value = status + if (status.code == LoginResultData.STATUS_NOT_SCAN + || status.code == LoginResultData.STATUS_SCANNING + ) { + delay(3000) + } else { + return@launch + } + }.onFailure { + _loginStatus.value = LoginResultData(-1, it.message ?: "") + return@launch + } + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/service/UserService.kt b/AAmusic/app/src/main/java/me/wcy/music/account/service/UserService.kt new file mode 100644 index 0000000..6a8c1ee --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/service/UserService.kt @@ -0,0 +1,30 @@ +package me.wcy.music.account.service + +import android.app.Activity +import kotlinx.coroutines.flow.StateFlow +import top.wangchenyan.common.model.CommonResult +import me.wcy.music.account.bean.ProfileData + +/** + * + */ +interface UserService { + val profile: StateFlow + + fun getCookie(): String + + fun isLogin(): Boolean + + fun getUserId(): Long + + suspend fun login(cookie: String): CommonResult + + suspend fun logout() + + fun checkLogin( + activity: Activity, + showDialog: Boolean = true, + onCancel: (() -> Unit)? = null, + onLogin: (() -> Unit)? = null + ) +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/service/UserServiceImpl.kt b/AAmusic/app/src/main/java/me/wcy/music/account/service/UserServiceImpl.kt new file mode 100644 index 0000000..1627faa --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/service/UserServiceImpl.kt @@ -0,0 +1,104 @@ +package me.wcy.music.account.service + +import android.app.Activity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import me.wcy.music.account.AccountApi +import me.wcy.music.account.AccountPreference +import me.wcy.music.account.bean.ProfileData +import me.wcy.music.consts.RoutePath +import me.wcy.music.net.NetCache +import me.wcy.router.CRouter +import top.wangchenyan.common.ext.showConfirmDialog +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.model.CommonResult +import javax.inject.Inject +import javax.inject.Singleton + +/** + * + */ +@Singleton +class UserServiceImpl @Inject constructor() : UserService { + private val _profile = MutableStateFlow(AccountPreference.profile) + override val profile = _profile.toUnMutable() + + override fun getCookie(): String { + return AccountPreference.cookie + } + + override fun isLogin(): Boolean { + return profile.value != null + } + + override fun getUserId(): Long { + return _profile.value?.userId ?: 0 + } + + override suspend fun login(cookie: String): CommonResult { + AccountPreference.cookie = cookie + val res = kotlin.runCatching { + AccountApi.get().getLoginStatus() + } + return if (res.isSuccess) { + val loginStatusData = res.getOrThrow() + val status = loginStatusData.data.account.status + if (status == 0 + && loginStatusData.data.profile != null + ) { + val profileData = loginStatusData.data.profile + _profile.value = profileData + AccountPreference.profile = profileData + CommonResult.success(profileData) + } else { + AccountPreference.cookie = "" + CommonResult.fail(status, msg = "login fail") + } + } else { + AccountPreference.cookie = "" + CommonResult.fail(msg = res.exceptionOrNull()?.message) + } + } + + override suspend fun logout() { + withContext(Dispatchers.IO) { + AccountPreference.clear() + NetCache.userCache.clear() + } + _profile.value = null + } + + override fun checkLogin( + activity: Activity, + showDialog: Boolean, + onCancel: (() -> Unit)?, + onLogin: (() -> Unit)? + ) { + if (isLogin()) { + onLogin?.invoke() + return + } + val startLogin = { + CRouter.with(activity).url(RoutePath.LOGIN).startForResult { + if (it.isSuccess()) { + onLogin?.invoke() + } + } + } + if (showDialog.not()) { + startLogin() + return + } + activity.showConfirmDialog( + title = "未登录", + message = "请先登录", + confirmButton = "去登录", + onCancelClick = { + onCancel?.invoke() + } + ) { + startLogin() + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/account/service/UserServiceModule.kt b/AAmusic/app/src/main/java/me/wcy/music/account/service/UserServiceModule.kt new file mode 100644 index 0000000..b745c8b --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/account/service/UserServiceModule.kt @@ -0,0 +1,32 @@ +package me.wcy.music.account.service + +import android.app.Application +import dagger.Binds +import dagger.Module +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.wcy.music.ext.accessEntryPoint + +/** + * + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class UserServiceModule { + + @Binds + abstract fun bindUserService(userServiceImpl: UserServiceImpl): UserService + + companion object { + fun Application.userService(): UserService { + return accessEntryPoint().userService() + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface UserServiceEntryPoint { + fun userService(): UserService + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/ApiDomainDialog.kt b/AAmusic/app/src/main/java/me/wcy/music/common/ApiDomainDialog.kt new file mode 100644 index 0000000..bed6e90 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/ApiDomainDialog.kt @@ -0,0 +1,94 @@ +package me.wcy.music.common + +import android.content.Context +import androidx.core.text.buildSpannedString +import com.blankj.utilcode.util.AppUtils +import com.blankj.utilcode.util.RegexUtils +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.account.AccountPreference +import me.wcy.music.databinding.DialogApiDomainBinding +import me.wcy.music.net.NetCache +import me.wcy.music.storage.preference.ConfigPreferences +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.ext.getColorEx +import top.wangchenyan.common.ext.setLink +import top.wangchenyan.common.ext.showConfirmDialog +import top.wangchenyan.common.ext.showSingleDialog +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.utils.LaunchUtils +import top.wangchenyan.common.widget.CustomSpan.appendStyle +import top.wangchenyan.common.widget.dialog.CenterDialog +import top.wangchenyan.common.widget.dialog.CenterDialogBuilder + +/** + * + */ +class ApiDomainDialog(private val context: Context) { + + fun show() { + CenterDialogBuilder(context) + .title("请输入云音乐API域名") + .contentViewBinding { dialog: CenterDialog, viewBinding: DialogApiDomainBinding -> + viewBinding.tvDoc.setLink() + viewBinding.tvDoc.text = buildSpannedString { + append("点击查看") + appendStyle( + "云音乐API文档", + color = context.getColorEx(R.color.common_theme_color) + ) { + LaunchUtils.launchBrowser( + context, + "https://binaryify.github.io/NeteaseCloudMusicApi" + ) + } + } + if (ConfigPreferences.apiDomain.isNotEmpty()) { + viewBinding.etInput.hint = ConfigPreferences.apiDomain + } + } + .buttonText( + context.getString(R.string.common_confirm), + context.getString(R.string.common_cancel) + ) + .onButtonClickListener { dialog, which -> + if (which == 0) { + val domain = + dialog.getContentViewBinding()?.etInput?.text?.toString() + if (RegexUtils.isURL(domain).not()) { + toast("请输入正确的域名") + } else if (domain!!.endsWith("/").not()) { + toast("域名需要以'/'结尾") + } else { + ConfigPreferences.apiDomain = domain + AccountPreference.clear() + CommonApp.appScope.launch { + NetCache.userCache.clear() + } + dialog.dismiss() + context.showSingleDialog("设置成功,重启后生效") { + AppUtils.relaunchApp(true) + } + } + } else { + dialog.dismiss() + } + } + .isAutoClose(false) + .build() + .show() + } + + companion object { + fun checkApiDomain(context: Context): Boolean { + return if (ConfigPreferences.apiDomain.isEmpty()) { + context.showConfirmDialog("请先设置云音乐API域名") { + ApiDomainDialog(context).show() + } + false + } else { + true + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicActivity.kt b/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicActivity.kt new file mode 100644 index 0000000..b19ab2e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicActivity.kt @@ -0,0 +1,23 @@ +package me.wcy.music.common + +import com.kingja.loadsir.callback.Callback +import me.wcy.music.widget.loadsir.SoundWaveLoadingCallback +import top.wangchenyan.common.ui.activity.BaseActivity + +/** + * + */ +abstract class BaseMusicActivity : BaseActivity() { + + override fun getLoadingCallback(): Callback { + return SoundWaveLoadingCallback() + } + + override fun showLoadSirLoading() { + loadService?.showCallback(SoundWaveLoadingCallback::class.java) + } + + companion object { + private const val TAG = "BaseMusicActivity" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicFragment.kt new file mode 100644 index 0000000..857d82a --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicFragment.kt @@ -0,0 +1,19 @@ +package me.wcy.music.common + +import com.kingja.loadsir.callback.Callback +import top.wangchenyan.common.ui.fragment.BaseFragment +import me.wcy.music.widget.loadsir.SoundWaveLoadingCallback + +/** + * + */ +abstract class BaseMusicFragment : BaseFragment() { + + override fun getLoadingCallback(): Callback { + return SoundWaveLoadingCallback() + } + + override fun showLoadSirLoading() { + loadService?.showCallback(SoundWaveLoadingCallback::class.java) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicRefreshFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicRefreshFragment.kt new file mode 100644 index 0000000..e92a407 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/BaseMusicRefreshFragment.kt @@ -0,0 +1,19 @@ +package me.wcy.music.common + +import com.kingja.loadsir.callback.Callback +import top.wangchenyan.common.ui.fragment.BaseRefreshFragment +import me.wcy.music.widget.loadsir.SoundWaveLoadingCallback + +/** + * + */ +abstract class BaseMusicRefreshFragment : BaseRefreshFragment() { + + override fun getLoadingCallback(): Callback { + return SoundWaveLoadingCallback() + } + + override fun showLoadSirLoading() { + loadService?.showCallback(SoundWaveLoadingCallback::class.java) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/DarkModeService.kt b/AAmusic/app/src/main/java/me/wcy/music/common/DarkModeService.kt new file mode 100644 index 0000000..a914f55 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/DarkModeService.kt @@ -0,0 +1,67 @@ +package me.wcy.music.common + +import android.content.res.Configuration +import androidx.appcompat.app.AppCompatDelegate +import com.blankj.utilcode.util.ActivityUtils +import me.wcy.music.storage.preference.ConfigPreferences +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DarkModeService @Inject constructor() { + + fun init() { + setDarkModeInternal(DarkMode.fromValue(ConfigPreferences.darkMode)) + } + + fun setDarkMode(mode: DarkMode) { + if (mode.value != ConfigPreferences.darkMode) { + ConfigPreferences.darkMode = mode.value + setDarkModeInternal(mode) + } + } + + private fun setDarkModeInternal(mode: DarkMode) { + AppCompatDelegate.setDefaultNightMode(mode.systemValue) + } + + fun isDarkMode(): Boolean { + val context = ActivityUtils.getTopActivity() ?: return false + val nightModeFlags = + context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + when (nightModeFlags) { + Configuration.UI_MODE_NIGHT_NO -> { + return false + } + + Configuration.UI_MODE_NIGHT_YES -> { + return true + } + } + return false + } + + sealed class DarkMode(val value: String, val systemValue: Int) { + object Auto : DarkMode("0", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + object Light : DarkMode("1", AppCompatDelegate.MODE_NIGHT_NO) + object Dark : DarkMode("2", AppCompatDelegate.MODE_NIGHT_YES) + + override fun hashCode(): Int { + return value.hashCode() + } + + override fun equals(other: Any?): Boolean { + return other is DarkMode && other.value == this.value + } + + companion object { + fun fromValue(value: String): DarkMode { + return when (value) { + "1" -> Light + "2" -> Dark + else -> Auto + } + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/MusicFragmentContainerActivity.kt b/AAmusic/app/src/main/java/me/wcy/music/common/MusicFragmentContainerActivity.kt new file mode 100644 index 0000000..fbb2511 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/MusicFragmentContainerActivity.kt @@ -0,0 +1,10 @@ +package me.wcy.music.common + +import dagger.hilt.android.AndroidEntryPoint +import top.wangchenyan.common.ui.activity.FragmentContainerActivity + +/** + * + */ +@AndroidEntryPoint +class MusicFragmentContainerActivity : FragmentContainerActivity() \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/OnItemClickListener.kt b/AAmusic/app/src/main/java/me/wcy/music/common/OnItemClickListener.kt new file mode 100644 index 0000000..8adbd72 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/OnItemClickListener.kt @@ -0,0 +1,8 @@ +package me.wcy.music.common + +/** + * + */ +interface OnItemClickListener { + fun onItemClick(item: T, position: Int) +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/OnItemClickListener2.kt b/AAmusic/app/src/main/java/me/wcy/music/common/OnItemClickListener2.kt new file mode 100644 index 0000000..f3dcbb7 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/OnItemClickListener2.kt @@ -0,0 +1,9 @@ +package me.wcy.music.common + +/** + * + */ +interface OnItemClickListener2 { + fun onItemClick(item: T, position: Int) + fun onMoreClick(item: T, position: Int) +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/SimpleMusicRefreshFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/common/SimpleMusicRefreshFragment.kt new file mode 100644 index 0000000..ce31f50 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/SimpleMusicRefreshFragment.kt @@ -0,0 +1,19 @@ +package me.wcy.music.common + +import com.kingja.loadsir.callback.Callback +import top.wangchenyan.common.ui.fragment.SimpleRefreshFragment +import me.wcy.music.widget.loadsir.SoundWaveLoadingCallback + +/** + * + */ +abstract class SimpleMusicRefreshFragment : SimpleRefreshFragment() { + + override fun getLoadingCallback(): Callback { + return SoundWaveLoadingCallback() + } + + override fun showLoadSirLoading() { + loadService?.showCallback(SoundWaveLoadingCallback::class.java) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/AlbumData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/AlbumData.kt new file mode 100644 index 0000000..00034c0 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/AlbumData.kt @@ -0,0 +1,32 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.utils.MusicUtils.asLargeCover +import me.wcy.music.utils.MusicUtils.asSmallCover + +/** + * + */ +data class AlbumData( + @SerializedName("id") + val id: Long = 0, + @SerializedName("name") + val name: String = "", + @Deprecated("Please use resized url") + @SerializedName("picUrl") + val picUrl: String = "", + @SerializedName("tns") + val tns: List = listOf(), + @SerializedName("pic_str") + val picStr: String = "", + @SerializedName("pic") + val pic: Long = 0 +) { + fun getSmallCover(): String { + return picUrl.asSmallCover() + } + + fun getLargeCover(): String { + return picUrl.asLargeCover() + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/ArtistData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/ArtistData.kt new file mode 100644 index 0000000..384b9b8 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/ArtistData.kt @@ -0,0 +1,17 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class ArtistData( + @SerializedName("id") + val id: Long = 0, + @SerializedName("name") + val name: String = "", + @SerializedName("tns") + val tns: List = listOf(), + @SerializedName("alias") + val alias: List = listOf() +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/LrcData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/LrcData.kt new file mode 100644 index 0000000..9f78edd --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/LrcData.kt @@ -0,0 +1,12 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +data class LrcData( + @SerializedName("version") + val version: Int = 0, + @SerializedName("lyric") + val lyric: String = "" +) { + fun isValid() = lyric.isNotEmpty() +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/LrcDataWrap.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/LrcDataWrap.kt new file mode 100644 index 0000000..b41e3df --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/LrcDataWrap.kt @@ -0,0 +1,13 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class LrcDataWrap( + @SerializedName("code") + val code: Int = -1, + @SerializedName("lrc") + val lrc: LrcData = LrcData() +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/OriginSongSimpleData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/OriginSongSimpleData.kt new file mode 100644 index 0000000..b7622dc --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/OriginSongSimpleData.kt @@ -0,0 +1,17 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class OriginSongSimpleData( + @SerializedName("songId") + val songId: Int = 0, + @SerializedName("name") + val name: String = "", + @SerializedName("artists") + val artists: List = listOf(), + @SerializedName("albumMeta") + val albumMeta: AlbumData = AlbumData() +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/PlaylistData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/PlaylistData.kt new file mode 100644 index 0000000..0c5fd5b --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/PlaylistData.kt @@ -0,0 +1,51 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.account.bean.ProfileData +import me.wcy.music.utils.MusicUtils.asLargeCover +import me.wcy.music.utils.MusicUtils.asSmallCover + +data class PlaylistData( + @SerializedName("id") + val id: Long = 0, + @SerializedName("name") + val name: String = "", + @Deprecated("Please use resized url") + @SerializedName("coverImgUrl", alternate = ["picUrl"]) + val coverImgUrl: String = "", + @SerializedName("creator") + val creator: ProfileData = ProfileData(), + @SerializedName("subscribed") + val subscribed: Boolean = false, + @SerializedName("trackCount") + val trackCount: Int = 0, + @SerializedName("userId") + val userId: Long = 0, + @SerializedName("playCount", alternate = ["playcount"]) + val playCount: Long = 0, + @SerializedName("bookCount") + val bookCount: Long = 0, + @SerializedName("specialType") + val specialType: Int = 0, + @SerializedName("description") + val description: String = "", + @SerializedName("tags") + val tags: List = emptyList(), + @SerializedName("highQuality") + val highQuality: Boolean = false, + @SerializedName("updateFrequency") + val updateFrequency: String = "", + @SerializedName("ToplistType") + val toplistType: String = "", +) { + @SerializedName("_songList") + var songList: List = emptyList() + + fun getSmallCover(): String { + return coverImgUrl.asSmallCover() + } + + fun getLargeCover(): String { + return coverImgUrl.asLargeCover() + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/QualityData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/QualityData.kt new file mode 100644 index 0000000..a9863df --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/QualityData.kt @@ -0,0 +1,19 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class QualityData( + @SerializedName("br") + val br: Int = 0, + @SerializedName("fid") + val fid: Int = 0, + @SerializedName("size") + val size: Int = 0, + @SerializedName("vd") + val vd: Int = 0, + @SerializedName("sr") + val sr: Int = 0 +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/SongData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/SongData.kt new file mode 100644 index 0000000..bcc050e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/SongData.kt @@ -0,0 +1,84 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +data class SongData( + @SerializedName("id") + val id: Long = 0, + @SerializedName("name") + val name: String = "", + @SerializedName("pst") + val pst: Int = 0, + @SerializedName("t") + val t: Int = 0, + @SerializedName("ar") + val ar: List = listOf(), + @SerializedName("pop") + val pop: Int = 0, + @SerializedName("st") + val st: Int = 0, + @SerializedName("rt") + val rt: String = "", + @SerializedName("fee") + val fee: Int = 0, + @SerializedName("v") + val v: Int = 0, + @SerializedName("cf") + val cf: String = "", + @SerializedName("al") + val al: AlbumData = AlbumData(), + @SerializedName("dt") + val dt: Long = 0, + @SerializedName("h") + val h: QualityData = QualityData(), + @SerializedName("m") + val m: QualityData = QualityData(), + @SerializedName("l") + val l: QualityData = QualityData(), + @SerializedName("sq") + val sq: QualityData = QualityData(), + @SerializedName("hr") + val hr: QualityData = QualityData(), + @SerializedName("cd") + val cd: String = "", + @SerializedName("no") + val no: Int = 0, + @SerializedName("ftype") + val ftype: Int = 0, + @SerializedName("djId") + val djId: Int = 0, + @SerializedName("copyright") + val copyright: Int = 0, + @SerializedName("s_id") + val sId: Int = 0, + @SerializedName("mark") + val mark: Int = 0, + @SerializedName("originCoverType") + val originCoverType: Int = 0, + @SerializedName("originSongSimpleData") + val originSongSimpleData: OriginSongSimpleData? = null, + @SerializedName("resourceState") + val resourceState: Boolean = false, + @SerializedName("version") + val version: Int = 0, + @SerializedName("single") + val single: Int = 0, + @SerializedName("rtype") + val rtype: Int = 0, + @SerializedName("mst") + val mst: Int = 0, + @SerializedName("cp") + val cp: Int = 0, + @SerializedName("mv") + val mv: Int = 0, + @SerializedName("publishTime") + val publishTime: Int = 0, + @SerializedName("reason") + val reason: String = "", + @SerializedName("tns") + val tns: List = listOf(), + @SerializedName("recommendReason") + val recommendReason: String = "", + @SerializedName("alg") + val alg: String = "" +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/bean/SongUrlData.kt b/AAmusic/app/src/main/java/me/wcy/music/common/bean/SongUrlData.kt new file mode 100644 index 0000000..6b5572e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/bean/SongUrlData.kt @@ -0,0 +1,44 @@ +package me.wcy.music.common.bean + +import com.google.gson.annotations.SerializedName + +data class SongUrlData( + @SerializedName("id") + val id: Long = 0, + @SerializedName("url") + val url: String = "", + @SerializedName("br") + val br: Int = 0, + @SerializedName("size") + val size: Int = 0, + @SerializedName("md5") + val md5: String = "", + @SerializedName("code") + val code: Int = 0, + @SerializedName("expi") + val expi: Int = 0, + @SerializedName("type") + val type: String = "", + @SerializedName("gain") + val gain: Double = 0.0, + @SerializedName("peak") + val peak: Int = 0, + @SerializedName("fee") + val fee: Int = 0, + @SerializedName("payed") + val payed: Int = 0, + @SerializedName("flag") + val flag: Int = 0, + @SerializedName("canExtend") + val canExtend: Boolean = false, + @SerializedName("level") + val level: String = "", + @SerializedName("encodeType") + val encodeType: String = "", + @SerializedName("urlSource") + val urlSource: Int = 0, + @SerializedName("rightSource") + val rightSource: Int = 0, + @SerializedName("time") + val time: Int = 0 +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/MenuItem.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/MenuItem.kt new file mode 100644 index 0000000..3bbe002 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/MenuItem.kt @@ -0,0 +1,20 @@ +package me.wcy.music.common.dialog.songmenu + +import android.view.View + +/** + * + */ +interface MenuItem { + val name: String + fun onClick(view: View) +} + +data class SimpleMenuItem( + override val name: String, + val onClick: (View) -> Unit = {} +) : MenuItem { + override fun onClick(view: View) { + onClick.invoke(view) + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/SongMoreMenuDialog.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/SongMoreMenuDialog.kt new file mode 100644 index 0000000..ee48dff --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/SongMoreMenuDialog.kt @@ -0,0 +1,84 @@ +package me.wcy.music.common.dialog.songmenu + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import com.blankj.utilcode.util.SizeUtils +import top.wangchenyan.common.widget.dialog.BottomDialog +import top.wangchenyan.common.widget.dialog.BottomDialogBuilder +import me.wcy.music.common.bean.SongData +import me.wcy.music.databinding.DialogSongMoreMenuBinding +import me.wcy.music.databinding.ItemSongMoreMenuBinding +import me.wcy.music.storage.db.entity.SongEntity +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.music.utils.getSimpleArtist + +/** + * + */ +class SongMoreMenuDialog { + private val context: Context + private var songEntity: SongEntity? = null + private var songData: SongData? = null + private val items = mutableListOf() + + constructor(context: Context, songEntity: SongEntity) { + this.context = context + this.songEntity = songEntity + } + + constructor(context: Context, songData: SongData) { + this.context = context + this.songData = songData + } + + fun setItems(items: List) = apply { + this.items.apply { + clear() + addAll(items) + } + } + + fun show() { + BottomDialogBuilder(context) + .contentViewBinding { dialog: BottomDialog, viewBinding: DialogSongMoreMenuBinding -> + bindSongInfo(viewBinding) + bindMenus(dialog, viewBinding) + } + .cancelable(true) + .build() + .show() + } + + @SuppressLint("SetTextI18n") + private fun bindSongInfo(viewBinding: DialogSongMoreMenuBinding) { + val songEntity = songEntity + val songData = songData + if (songEntity != null) { + viewBinding.ivCover.loadCover(songEntity.getSmallCover(), SizeUtils.dp2px(4f)) + viewBinding.tvTitle.text = "歌曲: ${songEntity.title}" + viewBinding.tvArtist.text = songEntity.artist + } else if (songData != null) { + viewBinding.ivCover.loadCover(songData.al.getSmallCover(), SizeUtils.dp2px(4f)) + viewBinding.tvTitle.text = "歌曲: ${songData.name}" + viewBinding.tvArtist.text = songData.getSimpleArtist() + } + } + + private fun bindMenus(dialog: BottomDialog, viewBinding: DialogSongMoreMenuBinding) { + viewBinding.menuContainer.removeAllViews() + items.forEach { item -> + ItemSongMoreMenuBinding.inflate( + LayoutInflater.from(context), + viewBinding.menuContainer, + true + ).apply { + root.text = item.name + root.setOnClickListener { + dialog.dismiss() + item.onClick(it) + } + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/AlbumMenuItem.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/AlbumMenuItem.kt new file mode 100644 index 0000000..ad221cd --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/AlbumMenuItem.kt @@ -0,0 +1,18 @@ +package me.wcy.music.common.dialog.songmenu.items + +import android.view.View +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.MenuItem +import top.wangchenyan.common.ext.toast + +/** + * + */ +class AlbumMenuItem(private val songData: SongData) : MenuItem { + override val name: String + get() = "专辑: ${songData.al.name}" + + override fun onClick(view: View) { + toast("敬请期待") + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/ArtistMenuItem.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/ArtistMenuItem.kt new file mode 100644 index 0000000..b068d06 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/ArtistMenuItem.kt @@ -0,0 +1,19 @@ +package me.wcy.music.common.dialog.songmenu.items + +import android.view.View +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.MenuItem +import me.wcy.music.utils.getSimpleArtist +import top.wangchenyan.common.ext.toast + +/** + * + */ +class ArtistMenuItem(private val songData: SongData) : MenuItem { + override val name: String + get() = "歌手: ${songData.getSimpleArtist()}" + + override fun onClick(view: View) { + toast("敬请期待") + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/CollectMenuItem.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/CollectMenuItem.kt new file mode 100644 index 0000000..fb28eb0 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/CollectMenuItem.kt @@ -0,0 +1,28 @@ +package me.wcy.music.common.dialog.songmenu.items + +import android.view.View +import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.CoroutineScope +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.MenuItem +import me.wcy.music.mine.collect.song.CollectSongFragment +import top.wangchenyan.common.ext.findActivity + +/** + * + */ +class CollectMenuItem( + private val scope: CoroutineScope, + private val songData: SongData +) : MenuItem { + override val name: String + get() = "收藏到歌单" + + override fun onClick(view: View) { + val activity = view.context.findActivity() + if (activity is FragmentActivity) { + CollectSongFragment.newInstance(songData.id) + .show(activity.supportFragmentManager, CollectSongFragment.TAG) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/CommentMenuItem.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/CommentMenuItem.kt new file mode 100644 index 0000000..44ee535 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/CommentMenuItem.kt @@ -0,0 +1,18 @@ +package me.wcy.music.common.dialog.songmenu.items + +import android.view.View +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.MenuItem +import top.wangchenyan.common.ext.toast + +/** + * + */ +class CommentMenuItem(private val songData: SongData) : MenuItem { + override val name: String + get() = "评论" + + override fun onClick(view: View) { + toast("敬请期待") + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/DeletePlaylistSongMenuItem.kt b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/DeletePlaylistSongMenuItem.kt new file mode 100644 index 0000000..3eb5a08 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/common/dialog/songmenu/items/DeletePlaylistSongMenuItem.kt @@ -0,0 +1,49 @@ +package me.wcy.music.common.dialog.songmenu.items + +import android.view.View +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import me.wcy.music.account.service.UserServiceModule.Companion.userService +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.MenuItem +import me.wcy.music.mine.MineApi +import top.wangchenyan.common.ext.findActivity +import top.wangchenyan.common.ext.showConfirmDialog +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ui.activity.BaseActivity + +/** + * + */ +class DeletePlaylistSongMenuItem( + private val playlistData: PlaylistData, + private val songData: SongData, + private val onDelete: (songData: SongData) -> Unit, +) : MenuItem { + override val name: String + get() = "删除" + + override fun onClick(view: View) { + val activity = view.context.findActivity() as? BaseActivity + activity ?: return + if (activity.application.userService().isLogin().not()) return + activity.showConfirmDialog(message = "确定将所选音乐从列表删除?") { + activity.lifecycleScope.launch { + val result = runCatching { + MineApi.get().collectSong(playlistData.id, songData.id.toString(), "del") + } + if (result.isSuccess) { + val body = result.getOrThrow().body + if (body.code == 200) { + onDelete.invoke(songData) + } else { + toast(body.message) + } + } else { + toast(result.exceptionOrNull()?.message) + } + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/consts/Consts.kt b/AAmusic/app/src/main/java/me/wcy/music/consts/Consts.kt new file mode 100644 index 0000000..ca5c7cb --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/consts/Consts.kt @@ -0,0 +1,10 @@ +package me.wcy.music.consts + +/** + * + */ +object Consts { + const val PAGE_COUNT = 20 + const val PAGE_COUNT_GRID = 21 + const val SEARCH_HISTORY_COUNT = 20 +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/consts/FilePath.kt b/AAmusic/app/src/main/java/me/wcy/music/consts/FilePath.kt new file mode 100644 index 0000000..1f25f1e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/consts/FilePath.kt @@ -0,0 +1,35 @@ +package me.wcy.music.consts + +import top.wangchenyan.common.CommonApp +import java.io.File + +/** + * + */ +object FilePath { + val httpCache: String + get() = "http".assembleExternalCachePath() + val logRootDir: String + get() = "log".assembleExternalFilePath() + val lrcDir: String + get() = "lrc".assembleExternalFilePath().mkdirs() + + fun getLogPath(type: String): String { + return logRootDir + File.separator + type + } + + private fun String.assembleExternalCachePath(): String { + return "${CommonApp.app.externalCacheDir}${File.separator}music_$this" + } + + private fun String.assembleExternalFilePath(): String { + return CommonApp.app.getExternalFilesDir("music_$this")?.path ?: "" + } + + private fun String.mkdirs() = apply { + val file = File(this) + if (!file.exists()) { + file.mkdirs() + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/consts/PreferenceName.kt b/AAmusic/app/src/main/java/me/wcy/music/consts/PreferenceName.kt new file mode 100644 index 0000000..f15ef1a --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/consts/PreferenceName.kt @@ -0,0 +1,14 @@ +package me.wcy.music.consts + +/** + * + */ +object PreferenceName { + val ACCOUNT = "account".assemble() + val CONFIG = "config".assemble() + val SEARCH = "search".assemble() + + private fun String.assemble(): String { + return "music_$this" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/consts/RoutePath.kt b/AAmusic/app/src/main/java/me/wcy/music/consts/RoutePath.kt new file mode 100644 index 0000000..9b65b53 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/consts/RoutePath.kt @@ -0,0 +1,17 @@ +package me.wcy.music.consts + +/** + * + */ +object RoutePath { + const val LOGIN = "/login" + const val PHONE_LOGIN = "/login/phone" + const val QRCODE_LOGIN = "/login/qrcode" + const val LOCAL_SONG = "/local_music" + const val RECOMMEND_SONG = "/recommend_song" + const val SEARCH = "/search" + const val PLAYLIST_DETAIL = "/playlist/detail" + const val PLAYLIST_SQUARE = "/playlist/square" + const val PLAYING = "/playing" + const val RANKING = "/ranking" +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/DiscoverApi.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/DiscoverApi.kt new file mode 100644 index 0000000..322b54b --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/DiscoverApi.kt @@ -0,0 +1,113 @@ +package me.wcy.music.discover + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.wcy.music.common.bean.LrcDataWrap +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.bean.SongUrlData +import me.wcy.music.discover.banner.BannerListData +import me.wcy.music.discover.playlist.detail.bean.PlaylistDetailData +import me.wcy.music.discover.playlist.detail.bean.SongListData +import me.wcy.music.discover.playlist.square.bean.PlaylistListData +import me.wcy.music.discover.playlist.square.bean.PlaylistTagListData +import me.wcy.music.discover.recommend.song.bean.RecommendSongListData +import me.wcy.music.net.HttpClient +import me.wcy.music.storage.preference.ConfigPreferences +import retrofit2.Retrofit +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query +import top.wangchenyan.common.net.NetResult +import top.wangchenyan.common.net.gson.GsonConverterFactory +import top.wangchenyan.common.utils.GsonUtils + +/** + * + */ +interface DiscoverApi { + + @POST("recommend/songs") + suspend fun getRecommendSongs(): NetResult + + @POST("recommend/resource") + suspend fun getRecommendPlaylists(): PlaylistListData + + @POST("song/url/v1") + suspend fun getSongUrl( + @Query("id") id: Long, + @Query("level") level: String, + ): NetResult> + + @POST("lyric") + suspend fun getLrc( + @Query("id") id: Long, + ): LrcDataWrap + + @POST("playlist/detail") + suspend fun getPlaylistDetail( + @Query("id") id: Long, + ): PlaylistDetailData + + @POST("playlist/track/all") + suspend fun getPlaylistSongList( + @Query("id") id: Long, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + @Query("timestamp") timestamp: Long? = null + ): SongListData + + @POST("playlist/hot") + suspend fun getPlaylistTagList(): PlaylistTagListData + + @POST("top/playlist") + suspend fun getPlaylistList( + @Query("cat") cat: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): PlaylistListData + + @POST("toplist") + suspend fun getRankingList(): PlaylistListData + + @GET("banner?type=2") + suspend fun getBannerList(): BannerListData + + companion object { + private const val SONG_LIST_LIMIT = 800 + + private val api: DiscoverApi by lazy { + val retrofit = Retrofit.Builder() + .baseUrl(ConfigPreferences.apiDomain) + .addConverterFactory(GsonConverterFactory.create(GsonUtils.gson, true)) + .client(HttpClient.okHttpClient) + .build() + retrofit.create(DiscoverApi::class.java) + } + + fun get(): DiscoverApi = api + + suspend fun getFullPlaylistSongList(id: Long, timestamp: Long? = null): SongListData { + return withContext(Dispatchers.IO) { + var offset = 0 + val list = mutableListOf() + while (true) { + val songList = get().getPlaylistSongList( + id, + limit = SONG_LIST_LIMIT, + offset = offset, + timestamp = timestamp + ) + if (songList.code != 200) { + throw Exception("code = ${songList.code}") + } + if (songList.songs.isEmpty()) { + break + } + list.addAll(songList.songs) + offset = list.size + } + return@withContext SongListData(200, list) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/banner/BannerData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/banner/BannerData.kt new file mode 100644 index 0000000..4575b3f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/banner/BannerData.kt @@ -0,0 +1,39 @@ +package me.wcy.music.discover.banner + +import com.google.gson.annotations.SerializedName +import me.wcy.music.common.bean.SongData + +data class BannerData( + @SerializedName("pic") + val pic: String = "", + @SerializedName("targetId") + val targetId: Long = 0, + @SerializedName("targetType") + val targetType: Int = 0, + @SerializedName("titleColor") + val titleColor: String = "", + @SerializedName("typeTitle") + val typeTitle: String = "", + @SerializedName("url") + val url: String = "", + @SerializedName("exclusive") + val exclusive: Boolean = false, + @SerializedName("encodeId") + val encodeId: String = "", + @SerializedName("song") + val song: SongData? = null, + @SerializedName("bannerId") + val bannerId: String = "", + @SerializedName("alg") + val alg: String = "", + @SerializedName("scm") + val scm: String = "", + @SerializedName("requestId") + val requestId: String = "", + @SerializedName("showAdTag") + val showAdTag: Boolean = false, + @SerializedName("s_ctrp") + val sCtrp: String = "", + @SerializedName("bannerBizType") + val bannerBizType: String = "" +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/banner/BannerListData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/banner/BannerListData.kt new file mode 100644 index 0000000..3031f2e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/banner/BannerListData.kt @@ -0,0 +1,13 @@ +package me.wcy.music.discover.banner + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class BannerListData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("banners") + val banners: List = emptyList(), +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/home/DiscoverFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/home/DiscoverFragment.kt new file mode 100644 index 0000000..2309002 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/home/DiscoverFragment.kt @@ -0,0 +1,296 @@ +package me.wcy.music.discover.home + +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.blankj.utilcode.util.ScreenUtils +import com.blankj.utilcode.util.SizeUtils +import com.youth.banner.adapter.BannerImageAdapter +import com.youth.banner.config.IndicatorConfig +import com.youth.banner.holder.BannerImageHolder +import com.youth.banner.indicator.CircleIndicator +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.account.service.UserService +import me.wcy.music.common.ApiDomainDialog +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentDiscoverBinding +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.discover.banner.BannerData +import me.wcy.music.discover.home.viewmodel.DiscoverViewModel +import me.wcy.music.discover.playlist.square.item.PlaylistItemBinder +import me.wcy.music.discover.ranking.discover.item.DiscoverRankingItemBinder +import me.wcy.music.main.MainActivity +import me.wcy.music.service.PlayerController +import me.wcy.music.storage.preference.ConfigPreferences +import me.wcy.music.utils.toMediaItem +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter +import top.wangchenyan.common.ext.load +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.utils.LaunchUtils +import top.wangchenyan.common.widget.decoration.SpacingDecoration +import javax.inject.Inject + +/** + * + */ +@AndroidEntryPoint +class DiscoverFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + + private val recommendPlaylistAdapter by lazy { + RAdapter() + } + private val rankingListAdapter by lazy { + RAdapter() + } + + @Inject + lateinit var userService: UserService + + @Inject + lateinit var playerController: PlayerController + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isUseLoadSir(): Boolean { + return true + } + + override fun getLoadSirTarget(): View { + return viewBinding.content + } + + override fun onReload() { + super.onReload() + checkApiDomain(true) + } + + override fun onLazyCreate() { + super.onLazyCreate() + + initTitle() + initBanner() + initTopButton() + initRecommendPlaylist() + initRankingList() + checkApiDomain(false) + } + + private fun initTitle() { + getTitleLayout()?.run { + addImageMenu( + R.drawable.ic_menu, + isDayNight = true, + isLeft = true + ).setOnClickListener { + val activity = requireActivity() + if (activity is MainActivity) { + activity.openDrawer() + } + } + } + getTitleLayout()?.getContentView()?.setOnClickListener { + if (ApiDomainDialog.checkApiDomain(requireContext())) { + CRouter.with(requireActivity()).url(RoutePath.SEARCH).start() + } + } + } + + private fun initBanner() { + viewBinding.banner.addBannerLifecycleObserver(this) + .setIndicator(CircleIndicator(requireContext())) + .setIndicatorGravity(IndicatorConfig.Direction.LEFT) + .setIndicatorMargins(IndicatorConfig.Margins().apply { + leftMargin = SizeUtils.dp2px(28f) + }) + .setAdapter(object : BannerImageAdapter(emptyList()) { + override fun onBindView( + holder: BannerImageHolder?, + data: BannerData?, + position: Int, + size: Int + ) { + holder?.imageView?.apply { + val padding = SizeUtils.dp2px(16f) + setPadding(padding, 0, padding, 0) + load(data?.pic ?: "", SizeUtils.dp2px(12f)) + setOnClickListener { + data ?: return@setOnClickListener + if (data.song != null) { + playerController.addAndPlay(data.song.toMediaItem()) + CRouter.with(context).url(RoutePath.PLAYING).start() + } else if (data.url.isNotEmpty()) { + LaunchUtils.launchBrowser(requireContext(), data.url) + } else if (data.targetId > 0) { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", data.targetId) + .start() + } + } + } + } + }) + lifecycleScope.launch { + viewModel.bannerList.collectLatest { + viewBinding.banner.isVisible = it.isNotEmpty() + viewBinding.bannerPlaceholder.isVisible = it.isEmpty() + if (it.isNotEmpty()) { + viewBinding.banner.setDatas(it) + } + } + } + } + + private fun initTopButton() { + viewBinding.btnRecommendSong.setOnClickListener { + CRouter.with(requireActivity()).url(RoutePath.RECOMMEND_SONG).start() + } + viewBinding.btnPrivateFm.setOnClickListener { + toast("敬请期待") + } + viewBinding.btnRecommendPlaylist.setOnClickListener { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_SQUARE) + .start() + } + viewBinding.btnRank.setOnClickListener { + CRouter.with(requireActivity()).url(RoutePath.RANKING).start() + } + } + + private fun initRecommendPlaylist() { + viewBinding.tvRecommendPlaylist.setOnClickListener { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_SQUARE) + .start() + } + val itemWidth = (ScreenUtils.getAppScreenWidth() - SizeUtils.dp2px(20f)) / 3 + recommendPlaylistAdapter.register(PlaylistItemBinder(itemWidth, true, object : + PlaylistItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData) { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", item.id) + .start() + } + + override fun onPlayClick(item: PlaylistData) { + playPlaylist(item, 0) + } + })) + viewBinding.rvRecommendPlaylist.adapter = recommendPlaylistAdapter + viewBinding.rvRecommendPlaylist.layoutManager = + LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + viewBinding.rvRecommendPlaylist.addItemDecoration( + SpacingDecoration(SizeUtils.dp2px(10f)) + ) + + val updateVisibility = { + if (userService.isLogin() && viewModel.recommendPlaylist.value.isNotEmpty()) { + viewBinding.tvRecommendPlaylist.isVisible = true + viewBinding.rvRecommendPlaylist.isVisible = true + } else { + viewBinding.tvRecommendPlaylist.isVisible = false + viewBinding.rvRecommendPlaylist.isVisible = false + } + } + + lifecycleScope.launch { + userService.profile.collectLatest { + updateVisibility() + } + } + + lifecycleScope.launch { + viewModel.recommendPlaylist.collectLatest { recommendPlaylist -> + updateVisibility() + recommendPlaylistAdapter.refresh(recommendPlaylist) + } + } + } + + private fun initRankingList() { + viewBinding.tvRankingList.setOnClickListener { + CRouter.with(requireActivity()) + .url(RoutePath.RANKING) + .start() + } + rankingListAdapter.register(DiscoverRankingItemBinder(object : + DiscoverRankingItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData, position: Int) { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", item.id) + .start() + } + + override fun onSongClick(item: PlaylistData, songPosition: Int) { + playPlaylist(item, songPosition) + } + })) + viewBinding.vpRankingList.apply { + val recyclerView = getChildAt(0) as RecyclerView + recyclerView.apply { + setPadding(SizeUtils.dp2px(16f), 0, SizeUtils.dp2px(16f), 0) + clipToPadding = false + } + orientation = ViewPager2.ORIENTATION_HORIZONTAL + adapter = rankingListAdapter + } + + viewModel.rankingList.observe(this) { rankingList -> + rankingList ?: return@observe + if (viewModel.rankingList.value?.isNotEmpty() == true) { + viewBinding.tvRankingList.isVisible = true + viewBinding.vpRankingList.isVisible = true + } else { + viewBinding.tvRankingList.isVisible = false + viewBinding.vpRankingList.isVisible = false + } + rankingListAdapter.refresh(rankingList) + } + } + + private fun checkApiDomain(isReload: Boolean) { + if (ConfigPreferences.apiDomain.isNotEmpty()) { + showLoadSirSuccess() + } else { + showLoadSirError("请先设置云音乐API域名") + if (isReload) { + ApiDomainDialog.checkApiDomain(requireContext()) + } + } + } + + private fun playPlaylist(playlistData: PlaylistData, songPosition: Int) { + lifecycleScope.launch { + showLoading() + kotlin.runCatching { + DiscoverApi.getFullPlaylistSongList(playlistData.id) + }.onSuccess { songListData -> + dismissLoading() + if (songListData.code == 200 && songListData.songs.isNotEmpty()) { + val songs = songListData.songs.map { it.toMediaItem() } + playerController.replaceAll(songs, songs.getOrElse(songPosition) { songs[0] }) + } + }.onFailure { + dismissLoading() + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/home/viewmodel/DiscoverViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/home/viewmodel/DiscoverViewModel.kt new file mode 100644 index 0000000..c3c4fac --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/home/viewmodel/DiscoverViewModel.kt @@ -0,0 +1,130 @@ +package me.wcy.music.discover.home.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.account.service.UserService +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.discover.banner.BannerData +import me.wcy.music.net.NetCache +import me.wcy.music.storage.preference.ConfigPreferences +import top.wangchenyan.common.ext.toUnMutable +import javax.inject.Inject + +/** + * + */ +@HiltViewModel +class DiscoverViewModel @Inject constructor( + private val userService: UserService +) : ViewModel() { + private val _bannerList = MutableStateFlow>(emptyList()) + val bannerList = _bannerList.toUnMutable() + + private val _recommendPlaylist = MutableStateFlow>(emptyList()) + val recommendPlaylist = _recommendPlaylist.toUnMutable() + + private val _rankingList = MutableLiveData>(emptyList()) + val rankingList = _rankingList.toUnMutable() + + init { + loadCache() + viewModelScope.launch { + userService.profile.collectLatest { profile -> + if (profile != null && ConfigPreferences.apiDomain.isNotEmpty()) { + loadRecommendPlaylist() + } + } + } + loadBanner() + loadRankingList() + } + + private fun loadCache() { + viewModelScope.launch { + val list = NetCache.globalCache.getJsonArray(CACHE_KEY_BANNER, BannerData::class.java) + ?: return@launch + _bannerList.value = list + } + if (userService.isLogin()) { + viewModelScope.launch { + val list = NetCache.userCache.getJsonArray( + CACHE_KEY_REC_PLAYLIST, + PlaylistData::class.java + ) ?: return@launch + _recommendPlaylist.value = list + } + } + viewModelScope.launch { + val list = + NetCache.globalCache.getJsonArray(CACHE_KEY_RANKING_LIST, PlaylistData::class.java) + ?: return@launch + _rankingList.postValue(list) + } + } + + private fun loadBanner() { + viewModelScope.launch { + kotlin.runCatching { + DiscoverApi.get().getBannerList() + }.onSuccess { + _bannerList.value = it.banners + NetCache.globalCache.putJson(CACHE_KEY_BANNER, it.banners) + }.onFailure { + } + } + } + + private fun loadRecommendPlaylist() { + viewModelScope.launch { + kotlin.runCatching { + DiscoverApi.get().getRecommendPlaylists() + }.onSuccess { + _recommendPlaylist.value = it.playlists + NetCache.userCache.putJson(CACHE_KEY_REC_PLAYLIST, it.playlists) + }.onFailure { + } + } + } + + private fun loadRankingList() { + viewModelScope.launch { + kotlin.runCatching { + DiscoverApi.get().getRankingList() + }.onSuccess { + val rankingList = it.playlists.take(5) + val deferredList = mutableListOf>() + rankingList.forEach { + val d = async { + val songListRes = kotlin.runCatching { + DiscoverApi.get().getPlaylistSongList(it.id, limit = 3) + } + if (songListRes.getOrNull()?.code == 200) { + it.songList = songListRes.getOrThrow().songs + } + } + deferredList.add(d) + } + deferredList.forEach { d -> + d.await() + } + _rankingList.postValue(rankingList) + NetCache.globalCache.putJson(CACHE_KEY_RANKING_LIST, rankingList) + }.onFailure { + } + } + } + + companion object { + const val CACHE_KEY_BANNER = "discover_banner" + const val CACHE_KEY_REC_PLAYLIST = "discover_recommend_playlist" + const val CACHE_KEY_RANKING_LIST = "discover_ranking_list" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/PlaylistDetailFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/PlaylistDetailFragment.kt new file mode 100644 index 0000000..e39c7b1 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/PlaylistDetailFragment.kt @@ -0,0 +1,238 @@ +package me.wcy.music.discover.playlist.detail + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.blankj.utilcode.util.SizeUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.account.service.UserService +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.SongMoreMenuDialog +import me.wcy.music.common.dialog.songmenu.items.AlbumMenuItem +import me.wcy.music.common.dialog.songmenu.items.ArtistMenuItem +import me.wcy.music.common.dialog.songmenu.items.CollectMenuItem +import me.wcy.music.common.dialog.songmenu.items.CommentMenuItem +import me.wcy.music.common.dialog.songmenu.items.DeletePlaylistSongMenuItem +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentPlaylistDetailBinding +import me.wcy.music.databinding.ItemPlaylistTagBinding +import me.wcy.music.discover.playlist.detail.item.PlaylistSongItemBinder +import me.wcy.music.discover.playlist.detail.viewmodel.PlaylistViewModel +import me.wcy.music.service.PlayerController +import me.wcy.music.utils.ConvertUtils +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.music.utils.toMediaItem +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter +import me.wcy.router.annotation.Route +import top.wangchenyan.common.ext.loadAvatar +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.utils.StatusBarUtils +import javax.inject.Inject + +/** + * + */ +@Route(RoutePath.PLAYLIST_DETAIL) +@AndroidEntryPoint +class PlaylistDetailFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + private val adapter by lazy { RAdapter() } + private var collectMenu: View? = null + + @Inject + lateinit var userService: UserService + + @Inject + lateinit var playerController: PlayerController + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isUseLoadSir(): Boolean { + return true + } + + override fun getLoadSirTarget(): View { + return viewBinding.coordinatorLayout + } + + override fun onReload() { + super.onReload() + loadData() + } + + override fun onLazyCreate() { + super.onLazyCreate() + + val id = getRouteArguments().getLongExtra("id", 0) + val realtimeData = getRouteArguments().getBooleanExtra("realtime_data", false) + val isLike = getRouteArguments().getBooleanExtra("is_like", false) + if (id <= 0) { + finish() + return + } + + viewModel.init(id, realtimeData, isLike) + + initTitle() + initPlaylistInfo() + initSongList() + loadData() + } + + private fun loadData() { + lifecycleScope.launch { + showLoadSirLoading() + val res = viewModel.loadData() + if (res.isSuccess()) { + showLoadSirSuccess() + } else { + showLoadSirError(res.msg) + } + } + } + + private fun initTitle() { + collectMenu = getTitleLayout()?.addImageMenu(R.drawable.ic_favorite_selector, false) + collectMenu?.setOnClickListener { + viewModel.playlistData.value ?: return@setOnClickListener + userService.checkLogin(requireActivity()) { + lifecycleScope.launch { + showLoading() + val res = viewModel.collect() + dismissLoading() + if (res.isSuccess()) { + toast("操作成功") + } else { + toast(res.msg) + } + } + } + } + + StatusBarUtils.getStatusBarHeight(requireActivity()) { + (viewBinding.titlePlaceholder.layoutParams as ViewGroup.MarginLayoutParams).apply { + topMargin = it + viewBinding.titlePlaceholder.requestLayout() + } + viewBinding.toolbarPlaceholder.layoutParams.height = + requireContext().resources.getDimensionPixelSize(R.dimen.common_title_bar_size) + it + viewBinding.toolbarPlaceholder.requestLayout() + } + + viewBinding.appBarLayout.addOnOffsetChangedListener { appBarLayout, verticalOffset -> + getTitleLayout()?.updateScroll(-verticalOffset) + } + + lifecycleScope.launch { + userService.profile.collectLatest { profile -> + updateCollectState() + } + } + } + + private fun initPlaylistInfo() { + lifecycleScope.launch { + viewModel.playlistData.collectLatest { playlistData -> + playlistData ?: return@collectLatest + getTitleLayout()?.setTitleText(playlistData.name) + updateCollectState() + viewBinding.ivCover.loadCover(playlistData.getSmallCover(), SizeUtils.dp2px(6f)) + viewBinding.tvPlayCount.text = + ConvertUtils.formatPlayCount(playlistData.playCount) + viewBinding.tvName.text = playlistData.name + viewBinding.ivCreatorAvatar.loadAvatar(playlistData.creator.avatarUrl) + viewBinding.tvCreatorName.text = playlistData.creator.nickname + viewBinding.tvSongCount.text = "(${playlistData.trackCount})" + + viewBinding.flTags.removeAllViews() + playlistData.tags.forEach { tag -> + ItemPlaylistTagBinding.inflate( + LayoutInflater.from(context), + viewBinding.flTags, + true + ).apply { + root.text = tag + } + } + + viewBinding.tvDesc.text = playlistData.description + } + } + } + + private fun initSongList() { + viewBinding.llPlayAll.setOnClickListener { + val songList = viewModel.songList.value.map { it.toMediaItem() } + if (songList.isNotEmpty()) { + playerController.replaceAll(songList, songList.first()) + CRouter.with(requireContext()).url(RoutePath.PLAYING).start() + } + } + + adapter.register(PlaylistSongItemBinder(object : OnItemClickListener2 { + override fun onItemClick(item: SongData, position: Int) { + val songList = viewModel.songList.value.map { it.toMediaItem() } + if (songList.isNotEmpty()) { + playerController.replaceAll(songList, songList[position]) + CRouter.with(requireContext()).url(RoutePath.PLAYING).start() + } + } + + override fun onMoreClick(item: SongData, position: Int) { + val items = mutableListOf( + CollectMenuItem(lifecycleScope, item), + CommentMenuItem(item), + ArtistMenuItem(item), + AlbumMenuItem(item) + ) + val playlistData = viewModel.playlistData.value + if (playlistData != null && playlistData.creator.userId == userService.getUserId()) { + items.add(DeletePlaylistSongMenuItem(playlistData, item) { + viewModel.removeSong(it) + }) + } + SongMoreMenuDialog(requireActivity(), item) + .setItems(items) + .show() + } + })) + viewBinding.recyclerView.adapter = adapter + + lifecycleScope.launch { + viewModel.songList.collectLatest { songList -> + adapter.refresh(songList) + } + } + } + + private fun updateCollectState() { + val playlistData = viewModel.playlistData.value + if (playlistData == null) { + collectMenu?.isVisible = false + return + } + if (userService.getUserId() == playlistData.userId) { + collectMenu?.isVisible = false + return + } + collectMenu?.isVisible = true + collectMenu?.isSelected = playlistData.subscribed + } + + override fun getNavigationBarColor(): Int { + return R.color.play_bar_bg + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/bean/PlaylistDetailData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/bean/PlaylistDetailData.kt new file mode 100644 index 0000000..56091fe --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/bean/PlaylistDetailData.kt @@ -0,0 +1,14 @@ +package me.wcy.music.discover.playlist.detail.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.common.bean.PlaylistData + +/** + * + */ +data class PlaylistDetailData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("playlist") + val playlist: PlaylistData = PlaylistData(), +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/bean/SongListData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/bean/SongListData.kt new file mode 100644 index 0000000..25d6fec --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/bean/SongListData.kt @@ -0,0 +1,14 @@ +package me.wcy.music.discover.playlist.detail.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.common.bean.SongData + +/** + * + */ +data class SongListData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("songs") + val songs: List = emptyList() +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/item/PlaylistSongItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/item/PlaylistSongItemBinder.kt new file mode 100644 index 0000000..9d3ca4a --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/item/PlaylistSongItemBinder.kt @@ -0,0 +1,34 @@ +package me.wcy.music.discover.playlist.detail.item + +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.bean.SongData +import me.wcy.music.databinding.ItemPlaylistSongBinding +import me.wcy.music.utils.getSimpleArtist +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class PlaylistSongItemBinder(private val listener: OnItemClickListener2) : + RItemBinder() { + + override fun onBind(viewBinding: ItemPlaylistSongBinding, item: SongData, position: Int) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.ivMore.setOnClickListener { + listener.onMoreClick(item, position) + } + viewBinding.tvIndex.text = (position + 1).toString() + viewBinding.tvTitle.text = item.name + viewBinding.tvSubTitle.text = buildString { + append(item.getSimpleArtist()) + append(" - ") + append(item.al.name) + item.originSongSimpleData?.let { originSong -> + append(" | 原唱: ") + append(originSong.artists.joinToString("/") { it.name }) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/viewmodel/PlaylistViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/viewmodel/PlaylistViewModel.kt new file mode 100644 index 0000000..27df630 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/detail/viewmodel/PlaylistViewModel.kt @@ -0,0 +1,93 @@ +package me.wcy.music.discover.playlist.detail.viewmodel + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.common.bean.SongData +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.mine.MineApi +import me.wcy.music.service.likesong.LikeSongProcessor +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.model.CommonResult +import top.wangchenyan.common.net.apiCall +import top.wangchenyan.common.utils.ServerTime +import javax.inject.Inject + +/** + * + */ +@HiltViewModel +class PlaylistViewModel @Inject constructor() : ViewModel() { + @Inject + lateinit var likeSongProcessor: LikeSongProcessor + + private val _playlistData = MutableStateFlow(null) + val playlistData = _playlistData.toUnMutable() + + private val _songList = MutableStateFlow>(emptyList()) + val songList = _songList.toUnMutable() + + private var playlistId = 0L + private var realtimeData = false + private var isLike = false + + fun init(playlistId: Long, realtimeData: Boolean, isLike: Boolean) { + this.playlistId = playlistId + this.realtimeData = realtimeData + this.isLike = isLike + } + + suspend fun loadData(): CommonResult { + val detailRes = kotlin.runCatching { + DiscoverApi.get().getPlaylistDetail(playlistId) + } + val songListRes = kotlin.runCatching { + val timestamp = if (realtimeData) ServerTime.currentTimeMillis() else null + DiscoverApi.getFullPlaylistSongList(playlistId, timestamp = timestamp) + } + return if (detailRes.isSuccess.not() || detailRes.getOrThrow().code != 200) { + CommonResult.fail(msg = detailRes.exceptionOrNull()?.message) + } else if (songListRes.isSuccess.not() || songListRes.getOrThrow().code != 200) { + CommonResult.fail(msg = songListRes.exceptionOrNull()?.message) + } else { + _playlistData.value = detailRes.getOrThrow().playlist + _songList.value = songListRes.getOrThrow().songs + CommonResult.success(Unit) + } + } + + suspend fun collect(): CommonResult { + val playlistData = _playlistData.value ?: return CommonResult.fail() + if (playlistData.subscribed) { + val res = apiCall { + MineApi.get().collectPlaylist(playlistData.id, 2) + } + return if (res.isSuccess()) { + _playlistData.value = playlistData.copy(subscribed = false) + CommonResult.success(Unit) + } else { + CommonResult.fail(res.code, res.msg) + } + } else { + val res = apiCall { + MineApi.get().collectPlaylist(playlistData.id, 1) + } + return if (res.isSuccess()) { + _playlistData.value = playlistData.copy(subscribed = true) + CommonResult.success(Unit) + } else { + CommonResult.fail(res.code, res.msg) + } + } + } + + fun removeSong(songData: SongData) { + val songList = _songList.value.toMutableList() + songList.remove(songData) + _songList.value = songList + if (isLike) { + likeSongProcessor.updateLikeSongList() + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/PlaylistSquareFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/PlaylistSquareFragment.kt new file mode 100644 index 0000000..7af81d2 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/PlaylistSquareFragment.kt @@ -0,0 +1,91 @@ +package me.wcy.music.discover.playlist.square + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.widget.pager.TabLayoutPager +import me.wcy.music.R +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentPlaylistSpuareBinding +import me.wcy.music.discover.playlist.square.viewmodel.PlaylistSquareViewModel +import me.wcy.router.annotation.Route + +/** + * + */ +@Route(RoutePath.PLAYLIST_SQUARE) +@AndroidEntryPoint +class PlaylistSquareFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + private var pager: TabLayoutPager? = null + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isUseLoadSir(): Boolean { + return true + } + + override fun getLoadSirTarget(): View { + return viewBinding.content + } + + override fun onReload() { + super.onReload() + loadTagList() + } + + override fun onLazyCreate() { + super.onLazyCreate() + initTab() + loadTagList() + } + + private fun initTab() { + lifecycleScope.launch { + viewModel.tagList.collectLatest { tagList -> + if (tagList.isNotEmpty() && pager == null) { + pager = TabLayoutPager( + lifecycle, + childFragmentManager, + viewBinding.viewPage2, + viewBinding.tabLayout + ).apply { + tagList.forEach { tag -> + addFragment(PlaylistTabFragment().apply { + arguments = Bundle().apply { + putString("tag", tag) + } + }, tag) + } + setup() + } + } + } + } + } + + private fun loadTagList() { + lifecycleScope.launch { + showLoadSirLoading() + val res = viewModel.loadTagList() + if (res.isSuccess()) { + showLoadSirSuccess() + } else { + showLoadSirError(res.msg) + } + } + } + + override fun getNavigationBarColor(): Int { + return R.color.play_bar_bg + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/PlaylistTabFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/PlaylistTabFragment.kt new file mode 100644 index 0000000..5cfe647 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/PlaylistTabFragment.kt @@ -0,0 +1,87 @@ +package me.wcy.music.discover.playlist.square + +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.blankj.utilcode.util.ScreenUtils +import com.blankj.utilcode.util.SizeUtils +import dagger.hilt.android.AndroidEntryPoint +import top.wangchenyan.common.model.CommonResult +import top.wangchenyan.common.widget.decoration.GridSpacingDecoration +import me.wcy.music.common.SimpleMusicRefreshFragment +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.consts.Consts +import me.wcy.music.consts.RoutePath +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.discover.playlist.square.item.PlaylistItemBinder +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter + +/** + * + */ +@AndroidEntryPoint +class PlaylistTabFragment : SimpleMusicRefreshFragment() { + private val cat by lazy { + getRouteArguments().getStringExtra("tag") ?: "" + } + + override fun isShowTitle(): Boolean { + return false + } + + override fun isRefreshEnabled(): Boolean { + return false + } + + override fun getLayoutManager(): RecyclerView.LayoutManager { + return GridLayoutManager(requireContext(), 3) + } + + override fun initAdapter(adapter: RAdapter) { + val itemWidth = (ScreenUtils.getAppScreenWidth() - SizeUtils.dp2px(52f)) / 3 + adapter.register( + PlaylistItemBinder( + itemWidth, + false, + object : PlaylistItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData) { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", item.id) + .start() + } + + override fun onPlayClick(item: PlaylistData) { + } + }) + ) + } + + override fun onLazyCreate() { + super.onLazyCreate() + getRecyclerView().apply { + val paddingHorizontal = SizeUtils.dp2px(16f) + val paddingVertical = SizeUtils.dp2px(10f) + setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical) + clipToPadding = false + val spacing = SizeUtils.dp2px(10f) + addItemDecoration(GridSpacingDecoration(spacing, spacing)) + } + } + + override suspend fun getData(page: Int): CommonResult> { + val res = kotlin.runCatching { + DiscoverApi.get() + .getPlaylistList( + cat, + Consts.PAGE_COUNT_GRID, + (page - 1) * Consts.PAGE_COUNT_GRID + ) + } + return if (res.getOrNull()?.code == 200) { + CommonResult.success(res.getOrThrow().playlists) + } else { + CommonResult.fail(msg = res.exceptionOrNull()?.message) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistListData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistListData.kt new file mode 100644 index 0000000..64179c7 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistListData.kt @@ -0,0 +1,14 @@ +package me.wcy.music.discover.playlist.square.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.common.bean.PlaylistData + +/** + * + */ +data class PlaylistListData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("playlists", alternate = ["playlist", "recommend", "list"]) + val playlists: List = emptyList(), +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistTagData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistTagData.kt new file mode 100644 index 0000000..a6a808d --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistTagData.kt @@ -0,0 +1,24 @@ +package me.wcy.music.discover.playlist.square.bean + +import com.google.gson.annotations.SerializedName + +data class PlaylistTagData( + @SerializedName("id") + val id: Long = 0, + @SerializedName("name") + val name: String = "", + @SerializedName("activity") + val activity: Boolean = false, + @SerializedName("hot") + val hot: Boolean = false, + @SerializedName("position") + val position: Int = 0, + @SerializedName("category") + val category: Int = 0, + @SerializedName("createTime") + val createTime: Long = 0, + @SerializedName("usedCount") + val usedCount: Long = 0, + @SerializedName("type") + val type: Int = 0 +) \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistTagListData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistTagListData.kt new file mode 100644 index 0000000..6867e73 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/bean/PlaylistTagListData.kt @@ -0,0 +1,13 @@ +package me.wcy.music.discover.playlist.square.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class PlaylistTagListData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("tags") + val tags: List = emptyList(), +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/item/PlaylistItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/item/PlaylistItemBinder.kt new file mode 100644 index 0000000..5544138 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/item/PlaylistItemBinder.kt @@ -0,0 +1,45 @@ +package me.wcy.music.discover.playlist.square.item + +import androidx.core.view.isVisible +import com.blankj.utilcode.util.SizeUtils +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.ItemDiscoverPlaylistBinding +import me.wcy.music.utils.ConvertUtils +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class PlaylistItemBinder( + private val itemWidth: Int, + private val showPlayButton: Boolean, + private val listener: OnItemClickListener +) : RItemBinder() { + + override fun onBind( + viewBinding: ItemDiscoverPlaylistBinding, + item: PlaylistData, + position: Int + ) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item) + } + viewBinding.ivPlay.isVisible = showPlayButton + viewBinding.ivPlay.setOnClickListener { + listener.onPlayClick(item) + } + val lp = viewBinding.ivCover.layoutParams + lp.width = itemWidth + lp.height = itemWidth + viewBinding.ivCover.layoutParams = lp + viewBinding.ivCover.loadCover(item.getSmallCover(), SizeUtils.dp2px(6f)) + viewBinding.tvPlayCount.text = ConvertUtils.formatPlayCount(item.playCount) + viewBinding.tvName.text = item.name + } + + interface OnItemClickListener { + fun onItemClick(item: PlaylistData) + fun onPlayClick(item: PlaylistData) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/viewmodel/PlaylistSquareViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/viewmodel/PlaylistSquareViewModel.kt new file mode 100644 index 0000000..eddcaab --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/playlist/square/viewmodel/PlaylistSquareViewModel.kt @@ -0,0 +1,29 @@ +package me.wcy.music.discover.playlist.square.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import me.wcy.music.discover.DiscoverApi +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.model.CommonResult + +/** + * + */ +class PlaylistSquareViewModel : ViewModel() { + private val _tagList = MutableStateFlow>(emptyList()) + val tagList = _tagList.toUnMutable() + + suspend fun loadTagList(): CommonResult { + val res = kotlin.runCatching { + DiscoverApi.get().getPlaylistTagList() + } + return if (res.getOrNull()?.code == 200) { + val list = res.getOrThrow().tags.map { it.name }.toMutableList() + list.add(0, "全部") + _tagList.value = list + CommonResult.success(Unit) + } else { + CommonResult.fail(msg = res.exceptionOrNull()?.message) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/RankingFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/RankingFragment.kt new file mode 100644 index 0000000..053a84d --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/RankingFragment.kt @@ -0,0 +1,172 @@ +package me.wcy.music.discover.ranking + +import android.view.View +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.viewbinding.ViewBinding +import com.blankj.utilcode.util.ScreenUtils +import com.blankj.utilcode.util.SizeUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentRankingBinding +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.discover.ranking.item.OfficialRankingItemBinder +import me.wcy.music.discover.ranking.item.RankingTitleItemBinding +import me.wcy.music.discover.ranking.item.SelectedRankingItemBinder +import me.wcy.music.discover.ranking.viewmodel.RankingViewModel +import me.wcy.music.service.PlayerController +import me.wcy.music.utils.toMediaItem +import me.wcy.radapter3.RAdapter +import me.wcy.radapter3.RItemBinder +import me.wcy.radapter3.RTypeMapper +import me.wcy.router.CRouter +import me.wcy.router.annotation.Route +import top.wangchenyan.common.ext.viewBindings +import javax.inject.Inject + +/** + * + */ +@Route(RoutePath.RANKING) +@AndroidEntryPoint +class RankingFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + private val adapter by lazy { RAdapter() } + + @Inject + lateinit var playerController: PlayerController + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isUseLoadSir(): Boolean { + return true + } + + override fun getLoadSirTarget(): View { + return viewBinding.recyclerView + } + + override fun onReload() { + super.onReload() + loadData() + } + + override fun onLazyCreate() { + super.onLazyCreate() + + initView() + initDataObserver() + loadData() + } + + private fun loadData() { + lifecycleScope.launch { + showLoadSirLoading() + val res = viewModel.loadData() + if (res.isSuccess()) { + showLoadSirSuccess() + } else { + showLoadSirError(res.msg) + } + } + } + + private fun initView() { + val itemWidth = (ScreenUtils.getAppScreenWidth() - SizeUtils.dp2px(52f)) / 3 + adapter.register(PlaylistData::class, object : RTypeMapper { + private val officialItemBinder = OfficialRankingItemBinder(object : + OfficialRankingItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData, position: Int) { + openRankingDetail(item) + } + + override fun onPlayClick(item: PlaylistData, position: Int) { + playPlaylist(item) + } + }) + private val selectedItemBinder = SelectedRankingItemBinder(itemWidth, + object : SelectedRankingItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData, position: Int) { + openRankingDetail(item) + } + + override fun onPlayClick(item: PlaylistData, position: Int) { + playPlaylist(item) + } + + override fun getFirstSelectedPosition(): Int { + val dataList = viewModel.rankingList.value ?: return -1 + return dataList.indexOfFirst { it is PlaylistData && it.toplistType.isEmpty() } + } + }) + + override fun map(data: PlaylistData): RItemBinder { + return if (data.toplistType.isNotEmpty()) { + officialItemBinder + } else { + selectedItemBinder + } + } + }) + adapter.register(RankingTitleItemBinding()) + viewBinding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 3).apply { + spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + val dataList = viewModel.rankingList.value ?: return 1 + val item = dataList.getOrNull(position) ?: return 1 + return if (item is RankingViewModel.TitleData + || (item is PlaylistData && item.toplistType.isNotEmpty()) + ) { + 3 + } else { + 1 + } + } + } + } + viewBinding.recyclerView.adapter = adapter + } + + private fun initDataObserver() { + viewModel.rankingList.observe(this) { rankingList -> + rankingList ?: return@observe + adapter.refresh(rankingList) + } + } + + private fun openRankingDetail(item: PlaylistData) { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", item.id) + .start() + } + + private fun playPlaylist(playlistData: PlaylistData) { + lifecycleScope.launch { + showLoading() + kotlin.runCatching { + DiscoverApi.getFullPlaylistSongList(playlistData.id) + }.onSuccess { songListData -> + dismissLoading() + if (songListData.code == 200 && songListData.songs.isNotEmpty()) { + val songs = songListData.songs.map { it.toMediaItem() } + playerController.replaceAll(songs, songs[0]) + } + }.onFailure { + dismissLoading() + } + } + } + + override fun getNavigationBarColor(): Int { + return R.color.play_bar_bg + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/discover/item/DiscoverRankingItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/discover/item/DiscoverRankingItemBinder.kt new file mode 100644 index 0000000..1a152de --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/discover/item/DiscoverRankingItemBinder.kt @@ -0,0 +1,61 @@ +package me.wcy.music.discover.ranking.discover.item + +import android.view.LayoutInflater +import androidx.core.view.get +import androidx.core.view.isEmpty +import androidx.core.view.isVisible +import com.blankj.utilcode.util.SizeUtils +import top.wangchenyan.common.ext.context +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.ItemDiscoverRankingBinding +import me.wcy.music.databinding.ItemDiscoverRankingSongBinding +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.music.utils.getSimpleArtist +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class DiscoverRankingItemBinder(private val listener: OnItemClickListener) : + RItemBinder() { + override fun onBind( + viewBinding: ItemDiscoverRankingBinding, + item: PlaylistData, + position: Int + ) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.tvName.text = item.name + if (viewBinding.llSongContainer.isEmpty()) { + for (i in 0 until 3) { + ItemDiscoverRankingSongBinding.inflate( + LayoutInflater.from(viewBinding.context), + viewBinding.llSongContainer, + true + ) + } + } + + for (i in 0 until 3) { + val itemBinding = ItemDiscoverRankingSongBinding.bind(viewBinding.llSongContainer[i]) + item.songList.getOrNull(i)?.also { songItem -> + itemBinding.root.isVisible = true + itemBinding.root.setOnClickListener { + listener.onSongClick(item, i) + } + itemBinding.ivCover.loadCover(songItem.al.getSmallCover(), SizeUtils.dp2px(4f)) + itemBinding.tvRank.text = (i + 1).toString() + itemBinding.tvTitle.text = songItem.name + itemBinding.tvSubTitle.text = songItem.getSimpleArtist() + } ?: { + itemBinding.root.isVisible = false + } + } + } + + interface OnItemClickListener { + fun onItemClick(item: PlaylistData, position: Int) + fun onSongClick(item: PlaylistData, songPosition: Int) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/OfficialRankingItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/OfficialRankingItemBinder.kt new file mode 100644 index 0000000..21771c3 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/OfficialRankingItemBinder.kt @@ -0,0 +1,72 @@ +package me.wcy.music.discover.ranking.item + +import android.view.LayoutInflater +import androidx.core.text.buildSpannedString +import androidx.core.view.get +import androidx.core.view.isEmpty +import androidx.core.view.isVisible +import com.blankj.utilcode.util.SizeUtils +import top.wangchenyan.common.ext.context +import top.wangchenyan.common.widget.CustomSpan.appendStyle +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.ItemOfficialRankingBinding +import me.wcy.music.databinding.ItemOfficialRankingSongBinding +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.music.utils.getSimpleArtist +import me.wcy.radapter3.RItemBinder +import kotlin.reflect.KClass + +/** + * + */ +class OfficialRankingItemBinder(private val listener: OnItemClickListener) : + RItemBinder() { + override fun onBind( + viewBinding: ItemOfficialRankingBinding, + item: PlaylistData, + position: Int + ) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.ivPlay.setOnClickListener { + listener.onPlayClick(item, position) + } + viewBinding.tvName.text = item.name + viewBinding.tvUpdateTime.text = item.updateFrequency + viewBinding.ivCover.loadCover(item.getSmallCover(), SizeUtils.dp2px(6f)) + if (viewBinding.llSongContainer.isEmpty()) { + for (i in 0 until 3) { + ItemOfficialRankingSongBinding.inflate( + LayoutInflater.from(viewBinding.context), + viewBinding.llSongContainer, + true + ) + } + } + for (i in 0 until 3) { + val songItem = item.songList.getOrNull(i) + val itemBinding = ItemOfficialRankingSongBinding.bind(viewBinding.llSongContainer[i]) + if (songItem == null) { + itemBinding.root.isVisible = false + } else { + itemBinding.root.isVisible = true + itemBinding.tvIndex.text = (i + 1).toString() + itemBinding.tvTitle.text = buildSpannedString { + appendStyle(songItem.name, isBold = true) + append(" - ") + append(songItem.getSimpleArtist()) + } + } + } + } + + override fun getViewBindingClazz(): KClass<*> { + return ItemOfficialRankingBinding::class + } + + interface OnItemClickListener { + fun onItemClick(item: PlaylistData, position: Int) + fun onPlayClick(item: PlaylistData, position: Int) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/RankingTitleItemBinding.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/RankingTitleItemBinding.kt new file mode 100644 index 0000000..0f5addb --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/RankingTitleItemBinding.kt @@ -0,0 +1,18 @@ +package me.wcy.music.discover.ranking.item + +import me.wcy.music.databinding.ItemRankingTitleBinding +import me.wcy.music.discover.ranking.viewmodel.RankingViewModel +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class RankingTitleItemBinding : RItemBinder() { + override fun onBind( + viewBinding: ItemRankingTitleBinding, + item: RankingViewModel.TitleData, + position: Int + ) { + viewBinding.root.text = item.title + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/SelectedRankingItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/SelectedRankingItemBinder.kt new file mode 100644 index 0000000..26d0bdd --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/item/SelectedRankingItemBinder.kt @@ -0,0 +1,58 @@ +package me.wcy.music.discover.ranking.item + +import android.view.Gravity +import android.widget.FrameLayout +import com.blankj.utilcode.util.SizeUtils +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.ItemSelectedRankingBinding +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.radapter3.RItemBinder +import kotlin.reflect.KClass + +/** + * + */ +class SelectedRankingItemBinder( + private val itemWidth: Int, + private val listener: OnItemClickListener +) : RItemBinder() { + override fun onBind( + viewBinding: ItemSelectedRankingBinding, + item: PlaylistData, + position: Int + ) { + viewBinding.content.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.ivPlay.setOnClickListener { + listener.onPlayClick(item, position) + } + val selectedPosition = position - listener.getFirstSelectedPosition() + val gravity = when (selectedPosition % 3) { + 0 -> Gravity.START + 1 -> Gravity.CENTER_HORIZONTAL + 2 -> Gravity.END + else -> Gravity.START + } + val lp = viewBinding.content.layoutParams as FrameLayout.LayoutParams + if (lp.width != itemWidth || lp.height != itemWidth || lp.gravity != gravity) { + lp.width = itemWidth + lp.height = itemWidth + lp.gravity = gravity + viewBinding.content.layoutParams = lp + } + viewBinding.tvName.text = item.name + viewBinding.tvUpdateTime.text = item.updateFrequency + viewBinding.ivCover.loadCover(item.getSmallCover(), SizeUtils.dp2px(6f)) + } + + override fun getViewBindingClazz(): KClass<*> { + return ItemSelectedRankingBinding::class + } + + interface OnItemClickListener { + fun onItemClick(item: PlaylistData, position: Int) + fun onPlayClick(item: PlaylistData, position: Int) + fun getFirstSelectedPosition(): Int + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/viewmodel/RankingViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/viewmodel/RankingViewModel.kt new file mode 100644 index 0000000..53519e6 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/ranking/viewmodel/RankingViewModel.kt @@ -0,0 +1,50 @@ +package me.wcy.music.discover.ranking.viewmodel + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import me.wcy.music.discover.DiscoverApi +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.model.CommonResult + +/** + * + */ +class RankingViewModel : ViewModel() { + private val _rankingList = MutableLiveData>() + val rankingList = _rankingList.toUnMutable() + + suspend fun loadData(): CommonResult { + val rankingListRes = kotlin.runCatching { + DiscoverApi.get().getRankingList() + } + if (rankingListRes.getOrNull()?.code == 200) { + val rankingList = rankingListRes.getOrThrow().playlists + val officialList = rankingList.filter { it.toplistType.isNotEmpty() } + val selectedList = rankingList.filter { it.toplistType.isEmpty() } + val finalList = + listOf(TitleData("官方榜")) + officialList + listOf(TitleData("精选榜")) + selectedList + _rankingList.value = finalList + viewModelScope.launch { + officialList.forEach { + val d = async { + val songListRes = kotlin.runCatching { + DiscoverApi.get().getPlaylistSongList(it.id, limit = 3) + } + if (songListRes.getOrNull()?.code == 200) { + it.songList = songListRes.getOrThrow().songs + _rankingList.value = finalList + } + } + } + } + return CommonResult.success(Unit) + } else { + return CommonResult.fail(msg = rankingListRes.exceptionOrNull()?.message) + } + } + + data class TitleData(val title: CharSequence) +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/RecommendSongFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/RecommendSongFragment.kt new file mode 100644 index 0000000..d9a270e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/RecommendSongFragment.kt @@ -0,0 +1,109 @@ +package me.wcy.music.discover.recommend.song + +import android.view.View +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.SongMoreMenuDialog +import me.wcy.music.common.dialog.songmenu.items.AlbumMenuItem +import me.wcy.music.common.dialog.songmenu.items.ArtistMenuItem +import me.wcy.music.common.dialog.songmenu.items.CollectMenuItem +import me.wcy.music.common.dialog.songmenu.items.CommentMenuItem +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentRecommendSongBinding +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.discover.recommend.song.item.RecommendSongItemBinder +import me.wcy.music.service.PlayerController +import me.wcy.music.utils.toMediaItem +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter +import me.wcy.router.annotation.Route +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.net.apiCall +import javax.inject.Inject + +/** + * + */ +@Route(RoutePath.RECOMMEND_SONG, needLogin = true) +@AndroidEntryPoint +class RecommendSongFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val adapter by lazy { + RAdapter() + } + + @Inject + lateinit var playerController: PlayerController + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isUseLoadSir(): Boolean { + return true + } + + override fun getLoadSirTarget(): View { + return viewBinding.content + } + + override fun onReload() { + super.onReload() + loadData() + } + + override fun onLazyCreate() { + super.onLazyCreate() + + adapter.register(RecommendSongItemBinder(object : OnItemClickListener2 { + override fun onItemClick(item: SongData, position: Int) { + val entityList = adapter.getDataList().map { it.toMediaItem() } + playerController.replaceAll(entityList, entityList[position]) + CRouter.with(requireContext()).url(RoutePath.PLAYING).start() + } + + override fun onMoreClick(item: SongData, position: Int) { + SongMoreMenuDialog(requireActivity(), item) + .setItems( + listOf( + CollectMenuItem(lifecycleScope, item), + CommentMenuItem(item), + ArtistMenuItem(item), + AlbumMenuItem(item) + ) + ) + .show() + } + })) + viewBinding.recyclerView.adapter = adapter + viewBinding.tvPlayAll.setOnClickListener { + val entityList = adapter.getDataList().map { it.toMediaItem() } + playerController.replaceAll(entityList, entityList.first()) + CRouter.with(requireContext()).url(RoutePath.PLAYING).start() + } + + loadData() + } + + private fun loadData() { + lifecycleScope.launch { + showLoadSirLoading() + val res = apiCall { DiscoverApi.get().getRecommendSongs() } + if (res.isSuccessWithData()) { + showLoadSirSuccess() + adapter.refresh(res.getDataOrThrow().dailySongs) + } else { + showLoadSirError(res.msg) + } + } + } + + override fun getNavigationBarColor(): Int { + return R.color.play_bar_bg + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/bean/RecommendSongListData.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/bean/RecommendSongListData.kt new file mode 100644 index 0000000..226d877 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/bean/RecommendSongListData.kt @@ -0,0 +1,12 @@ +package me.wcy.music.discover.recommend.song.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.common.bean.SongData + +/** + * + */ +data class RecommendSongListData( + @SerializedName("dailySongs") + val dailySongs: List = emptyList() +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/item/RecommendSongItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/item/RecommendSongItemBinder.kt new file mode 100644 index 0000000..d479cab --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/discover/recommend/song/item/RecommendSongItemBinder.kt @@ -0,0 +1,39 @@ +package me.wcy.music.discover.recommend.song.item + +import androidx.core.view.isVisible +import com.blankj.utilcode.util.SizeUtils +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.bean.SongData +import me.wcy.music.databinding.ItemRecommendSongBinding +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.music.utils.getSimpleArtist +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class RecommendSongItemBinder(private val listener: OnItemClickListener2) : + RItemBinder() { + + override fun onBind(viewBinding: ItemRecommendSongBinding, item: SongData, position: Int) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.ivMore.setOnClickListener { + listener.onMoreClick(item, position) + } + viewBinding.ivCover.loadCover(item.al.getSmallCover(), SizeUtils.dp2px(4f)) + viewBinding.tvTitle.text = item.name + viewBinding.tvTag.isVisible = item.recommendReason.isNotEmpty() + viewBinding.tvTag.text = item.recommendReason + viewBinding.tvSubTitle.text = buildString { + append(item.getSimpleArtist()) + append(" - ") + append(item.al.name) + item.originSongSimpleData?.let { originSong -> + append(" | 原唱: ") + append(originSong.artists.joinToString("/") { it.name }) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/download/DownloadMusicInfo.kt b/AAmusic/app/src/main/java/me/wcy/music/download/DownloadMusicInfo.kt new file mode 100644 index 0000000..b851924 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/download/DownloadMusicInfo.kt @@ -0,0 +1,10 @@ +package me.wcy.music.download + +/** + * + */ +class DownloadMusicInfo( + val title: String?, + val musicPath: String, + val coverPath: String? +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/download/DownloadReceiver.kt b/AAmusic/app/src/main/java/me/wcy/music/download/DownloadReceiver.kt new file mode 100644 index 0000000..7856ae6 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/download/DownloadReceiver.kt @@ -0,0 +1,25 @@ +package me.wcy.music.download + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.LongSparseArray +import top.wangchenyan.common.ext.toast +import me.wcy.music.R + +/** + * 下载完成广播接收器 + * + */ +class DownloadReceiver : BroadcastReceiver() { + private val mDownloadList = LongSparseArray() + + override fun onReceive(context: Context, intent: Intent) { + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) + val downloadMusicInfo = mDownloadList.get(id) + if (downloadMusicInfo != null) { + toast(context.getString(R.string.download_success, downloadMusicInfo.title)) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/ext/ContextEx.kt b/AAmusic/app/src/main/java/me/wcy/music/ext/ContextEx.kt new file mode 100644 index 0000000..d94fa5e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/ext/ContextEx.kt @@ -0,0 +1,24 @@ +package me.wcy.music.ext + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.IntentFilter +import android.os.Build +import dagger.hilt.android.EntryPointAccessors + +/** + * + */ + +inline fun Application.accessEntryPoint(): T { + return EntryPointAccessors.fromApplication(this, T::class.java) +} + +fun Context.registerReceiverCompat(receiver: BroadcastReceiver, filter: IntentFilter) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED) + } else { + registerReceiver(receiver, filter) + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/AboutActivity.kt b/AAmusic/app/src/main/java/me/wcy/music/main/AboutActivity.kt new file mode 100644 index 0000000..c2d58f4 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/AboutActivity.kt @@ -0,0 +1,93 @@ +package me.wcy.music.main + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.blankj.utilcode.util.AppUtils +import me.wcy.music.R +import top.wangchenyan.common.ui.activity.BaseActivity + +class AboutActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_about) + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, AboutFragment()) + .commit() + } + + class AboutFragment : PreferenceFragmentCompat() { + private val mVersion: Preference by lazy { + findPreference("version")!! + } + private val mShare: Preference by lazy { + findPreference("share")!! + } + private val mStar: Preference by lazy { + findPreference("star")!! + } + private val mWeibo: Preference by lazy { + findPreference("weibo")!! + } + private val mBlog: Preference by lazy { + findPreference("blog")!! + } + private val mGithub: Preference by lazy { + findPreference("github")!! + } + private val api: Preference by lazy { + findPreference("api")!! + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.preference_about) + mVersion.summary = AppUtils.getAppVersionName() + mShare.setOnPreferenceClickListener { + share() + true + } + mStar.setOnPreferenceClickListener { + openUrl(getString(R.string.about_project_url)) + true + } + mWeibo.setOnPreferenceClickListener { + openUrl(it.summary.toString()) + true + } + mBlog.setOnPreferenceClickListener { + openUrl(it.summary.toString()) + true + } + mGithub.setOnPreferenceClickListener { + openUrl(it.summary.toString()) + true + } + api.setOnPreferenceClickListener { + openUrl("https://github.com/Binaryify/NeteaseCloudMusicApi") + true + } + } + + private fun share() { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + intent.putExtra( + Intent.EXTRA_TEXT, + getString( + R.string.share_app, + getString(R.string.app_name), + getString(R.string.about_project_url) + ) + ) + startActivity(Intent.createChooser(intent, getString(R.string.share))) + } + + private fun openUrl(url: String) { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + startActivity(intent) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/MainActivity.kt b/AAmusic/app/src/main/java/me/wcy/music/main/MainActivity.kt new file mode 100644 index 0000000..d095dc9 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/MainActivity.kt @@ -0,0 +1,218 @@ +package me.wcy.music.main + +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import androidx.annotation.DrawableRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.GravityCompat +import androidx.lifecycle.lifecycleScope +import com.google.android.material.navigation.NavigationView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.account.service.UserService +import me.wcy.music.common.ApiDomainDialog +import me.wcy.music.common.BaseMusicActivity +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.ActivityMainBinding +import me.wcy.music.databinding.NavigationHeaderBinding +import me.wcy.music.databinding.TabItemBinding +import me.wcy.music.service.MusicService +import me.wcy.music.service.PlayServiceModule +import me.wcy.music.service.PlayServiceModule.playerController +import me.wcy.music.utils.QuitTimer +import me.wcy.music.utils.TimeUtils +import me.wcy.router.CRouter +import top.wangchenyan.common.ext.showConfirmDialog +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.widget.pager.CustomTabPager +import javax.inject.Inject + +/** + * + */ +@AndroidEntryPoint +class MainActivity : BaseMusicActivity() { + private val viewBinding by viewBindings() + private val quitTimer by lazy { + QuitTimer(onTimerListener) + } + private var timerItem: MenuItem? = null + + @Inject + lateinit var userService: UserService + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + PlayServiceModule.isPlayerReady.observe(this) { isReady -> + if (isReady) { + setContentView(viewBinding.root) + + CustomTabPager(lifecycle, supportFragmentManager, viewBinding.viewPager).apply { + NaviTab.ALL.onEach { + val tabItem = getTabItem(it.icon, it.name) + addFragment(it.newFragment(), tabItem.root) + } + setScrollable(false) + setup() + } + + initDrawer() + parseIntent() + } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + parseIntent() + } + + private fun initDrawer() { + val navigationHeaderBinding = NavigationHeaderBinding.inflate( + LayoutInflater.from(this), + viewBinding.navigationView, + false + ) + viewBinding.navigationView.addHeaderView(navigationHeaderBinding.root) + viewBinding.navigationView.setNavigationItemSelectedListener(onMenuSelectListener) + lifecycleScope.launch { + userService.profile.collectLatest { profile -> + val menuLogout = viewBinding.navigationView.menu.findItem(R.id.action_logout) + menuLogout.isVisible = profile != null + } + } + } + + fun openDrawer() { + if (viewBinding.drawerLayout.isDrawerOpen(GravityCompat.START).not()) { + viewBinding.drawerLayout.openDrawer(GravityCompat.START) + } + } + + private fun parseIntent() { + val intent = intent + if (intent.hasExtra(MusicService.EXTRA_NOTIFICATION)) { + if (application.playerController().currentSong.value != null) { + CRouter.with(this).url(RoutePath.PLAYING).start() + } + setIntent(Intent()) + } + } + + private val onMenuSelectListener = object : NavigationView.OnNavigationItemSelectedListener { + override fun onNavigationItemSelected(item: MenuItem): Boolean { + viewBinding.drawerLayout.closeDrawers() + lifecycleScope.launch { + delay(1000) + item.isChecked = false + } + when (item.itemId) { + R.id.action_domain_setting -> { + ApiDomainDialog(this@MainActivity).show() + return true + } + + R.id.action_setting -> { + CRouter.with(this@MainActivity).url("/settings").start() + return true + } + + R.id.action_timer -> { + timerDialog() + return true + } + + R.id.action_logout -> { + logout() + return true + } + + R.id.action_exit -> { + exitApp() + return true + } + + R.id.action_about -> { + startActivity(Intent(this@MainActivity, AboutActivity::class.java)) + return true + } + } + return false + } + } + + private val onTimerListener = object : QuitTimer.OnTimerListener { + override fun onTick(remain: Long) { + if (timerItem == null) { + timerItem = viewBinding.navigationView.menu.findItem(R.id.action_timer) + } + val title = getString(R.string.menu_timer) + timerItem?.title = if (remain == 0L) { + title + } else { + TimeUtils.formatTime("$title(mm:ss)", remain) + } + } + + override fun onTimeEnd() { + exitApp() + } + } + + private fun timerDialog() { + AlertDialog.Builder(this) + .setTitle(R.string.menu_timer) + .setItems(resources.getStringArray(R.array.timer_text)) { dialog: DialogInterface?, which: Int -> + val times = resources.getIntArray(R.array.timer_int) + startTimer(times[which]) + } + .show() + } + + private fun startTimer(minute: Int) { + quitTimer.start((minute * 60 * 1000).toLong()) + if (minute > 0) { + toast(getString(R.string.timer_set, minute.toString())) + } else { + toast(R.string.timer_cancel) + } + } + + private fun logout() { + showConfirmDialog(message = "确认退出登录?") { + lifecycleScope.launch { + userService.logout() + } + } + } + + private fun exitApp() { + application.playerController().stop() + finish() + } + + private fun getTabItem(@DrawableRes icon: Int, text: CharSequence): TabItemBinding { + val binding = TabItemBinding.inflate(layoutInflater, viewBinding.tabBar, true) + binding.ivIcon.setImageResource(icon) + binding.tvTitle.text = text + return binding + } + + override fun getNavigationBarColor(): Int { + return R.color.tab_bg + } + + override fun onDestroy() { + super.onDestroy() + quitTimer.stop() + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/NaviTab.kt b/AAmusic/app/src/main/java/me/wcy/music/main/NaviTab.kt new file mode 100644 index 0000000..2844cf7 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/NaviTab.kt @@ -0,0 +1,47 @@ +package me.wcy.music.main + +import androidx.annotation.DrawableRes +import androidx.fragment.app.Fragment +import me.wcy.music.R +import me.wcy.music.discover.home.DiscoverFragment +import me.wcy.music.mine.home.MineFragment + +sealed class NaviTab private constructor( + val id: String, + @DrawableRes + val icon: Int, + val name: String, + val newFragment: () -> Fragment +) { + object Discover : NaviTab( + "discover", + R.drawable.ic_tab_discover, + "发现", + { DiscoverFragment() } + ) + + object Mine : NaviTab( + "mine", + R.drawable.ic_tab_mine, + "我的", + { MineFragment() } + ) + + fun getPosition(): Int { + return ALL.indexOf(this) + } + + companion object { + val ALL: List = listOf( + Discover, Mine + ) + + fun findByPosition(position: Int): NaviTab? { + return ALL.getOrNull(position) + } + + fun findByName(name: String): NaviTab? { + return ALL.find { it.id == name } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/SettingsActivity.kt b/AAmusic/app/src/main/java/me/wcy/music/main/SettingsActivity.kt new file mode 100644 index 0000000..56d0d0d --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/SettingsActivity.kt @@ -0,0 +1,193 @@ +package me.wcy.music.main + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.media.audiofx.AudioEffect +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import dagger.hilt.android.AndroidEntryPoint +import me.wcy.music.R +import me.wcy.music.common.BaseMusicActivity +import me.wcy.music.common.DarkModeService +import me.wcy.music.consts.PreferenceName +import me.wcy.music.service.PlayerController +import me.wcy.music.storage.preference.ConfigPreferences +import me.wcy.music.utils.MusicUtils +import me.wcy.router.annotation.Route +import top.wangchenyan.common.ext.toast +import javax.inject.Inject + +@Route("/settings") +@AndroidEntryPoint +class SettingsActivity : BaseMusicActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + val fragment = SettingsFragment() + supportFragmentManager.beginTransaction() + .replace(R.id.fragment_container, fragment) + .commitAllowingStateLoss() + } + + @AndroidEntryPoint + class SettingsFragment : PreferenceFragmentCompat() { + private val darkMode: Preference by lazy { + findPreference(getString(R.string.setting_key_dark_mode))!! + } + private val playSoundQuality: Preference by lazy { + findPreference(getString(R.string.setting_key_play_sound_quality))!! + } + private val soundEffect: Preference by lazy { + findPreference(getString(R.string.setting_key_sound_effect))!! + } + private val downloadSoundQuality: Preference by lazy { + findPreference(getString(R.string.setting_key_download_sound_quality))!! + } + private val filterSize: Preference by lazy { + findPreference(getString(R.string.setting_key_filter_size))!! + } + private val filterTime: Preference by lazy { + findPreference(getString(R.string.setting_key_filter_time))!! + } + + @Inject + lateinit var playerController: PlayerController + + @Inject + lateinit var darkModeService: DarkModeService + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = PreferenceName.CONFIG + addPreferencesFromResource(R.xml.preference_setting) + + initDarkMode() + initPlaySoundQuality() + initSoundEffect() + initDownloadSoundQuality() + initFilter() + } + + private fun initDarkMode() { + darkMode.summary = getSummary( + ConfigPreferences.darkMode, + R.array.dark_mode_entries, + R.array.dark_mode_values + ) + darkMode.setOnPreferenceChangeListener { preference, newValue -> + val value = newValue.toString() + filterSize.summary = getSummary( + value, + R.array.dark_mode_entries, + R.array.dark_mode_values + ) + val mode = DarkModeService.DarkMode.fromValue(value) + darkModeService.setDarkMode(mode) + true + } + } + + private fun initPlaySoundQuality() { + playSoundQuality.summary = getSummary( + ConfigPreferences.playSoundQuality, + R.array.sound_quality_entries, + R.array.sound_quality_entry_values + ) + playSoundQuality.setOnPreferenceChangeListener { preference, newValue -> + val value = newValue.toString() + playSoundQuality.summary = getSummary( + value, + R.array.sound_quality_entries, + R.array.sound_quality_entry_values + ) + true + } + } + + private fun initSoundEffect() { + soundEffect.setOnPreferenceClickListener { + startEqualizer() + true + } + } + + private fun initDownloadSoundQuality() { + downloadSoundQuality.summary = getSummary( + ConfigPreferences.downloadSoundQuality, + R.array.sound_quality_entries, + R.array.sound_quality_entry_values + ) + downloadSoundQuality.setOnPreferenceChangeListener { preference, newValue -> + val value = newValue.toString() + downloadSoundQuality.summary = getSummary( + value, + R.array.sound_quality_entries, + R.array.sound_quality_entry_values + ) + true + } + } + + private fun initFilter() { + filterSize.summary = getSummary( + ConfigPreferences.filterSize, + R.array.filter_size_entries, + R.array.filter_size_entry_values + ) + filterSize.setOnPreferenceChangeListener { preference, newValue -> + val value = newValue.toString() + filterSize.summary = getSummary( + value, + R.array.filter_size_entries, + R.array.filter_size_entry_values + ) + true + } + + filterTime.summary = getSummary( + ConfigPreferences.filterTime, + R.array.filter_time_entries, + R.array.filter_time_entry_values + ) + filterTime.setOnPreferenceChangeListener { preference, newValue -> + val value = newValue.toString() + filterTime.summary = getSummary( + value, + R.array.filter_time_entries, + R.array.filter_time_entry_values + ) + true + } + } + + private fun startEqualizer() { + if (MusicUtils.isAudioControlPanelAvailable(requireContext())) { + val intent = Intent() + val packageName = requireContext().packageName + intent.action = AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL + intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + intent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + intent.putExtra( + AudioEffect.EXTRA_AUDIO_SESSION, + playerController.getAudioSessionId() + ) + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + toast(R.string.device_not_support) + } + } else { + toast(R.string.device_not_support) + } + } + + private fun getSummary(value: String, entries: Int, values: Int): String { + val entryArray = resources.getStringArray(entries) + val valueArray = resources.getStringArray(values) + val index = valueArray.indexOf(value).coerceAtLeast(0) + return entryArray[index] + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/playing/PlayingActivity.kt b/AAmusic/app/src/main/java/me/wcy/music/main/playing/PlayingActivity.kt new file mode 100644 index 0000000..8137003 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/playing/PlayingActivity.kt @@ -0,0 +1,393 @@ +package me.wcy.music.main.playing + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.BitmapFactory +import android.media.AudioManager +import android.os.Bundle +import android.text.format.DateUtils +import android.util.Log +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import com.gyf.immersionbar.ImmersionBar +import dagger.hilt.android.AndroidEntryPoint +import jp.wasabeef.blurry.Blurry +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.lrcview.LrcView +import me.wcy.music.R +import me.wcy.music.common.BaseMusicActivity +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.ActivityPlayingBinding +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.ext.registerReceiverCompat +import me.wcy.music.main.playlist.CurrentPlaylistFragment +import me.wcy.music.service.PlayMode +import me.wcy.music.service.PlayerController +import me.wcy.music.service.likesong.LikeSongProcessor +import me.wcy.music.storage.LrcCache +import me.wcy.music.storage.preference.ConfigPreferences +import me.wcy.music.utils.TimeUtils +import me.wcy.music.utils.getDuration +import me.wcy.music.utils.getSongId +import me.wcy.music.utils.isLocal +import me.wcy.router.annotation.Route +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.net.apiCall +import top.wangchenyan.common.utils.LaunchUtils +import top.wangchenyan.common.utils.StatusBarUtils +import top.wangchenyan.common.utils.image.ImageUtils +import java.io.File +import javax.inject.Inject +import kotlin.math.abs + +/** + * + */ +@Route(RoutePath.PLAYING) +@AndroidEntryPoint +class PlayingActivity : BaseMusicActivity() { + private val viewBinding by viewBindings() + + @Inject + lateinit var playerController: PlayerController + + @Inject + lateinit var likeSongProcessor: LikeSongProcessor + + private val audioManager by lazy { + getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + + private val defaultCoverBitmap by lazy { + BitmapFactory.decodeResource(resources, R.drawable.bg_playing_default_cover) + } + + private var loadLrcJob: Job? = null + + private var lastProgress = 0 + private var isDraggingProgress = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(viewBinding.root) + + initTitle() + initVolume() + initCover() + initLrc() + initActions() + initPlayControl() + initData() + switchCoverLrc(true) + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + if (StatusBarUtils.isSupportStatusBarTransparent()) { + ImmersionBar.with(this) + .transparentNavigationBar() + .navigationBarDarkIcon(false) + .init() + } + } + + private fun initTitle() { + viewBinding.ivClose.setOnClickListener { + onBackPressed() + } + } + + private fun initVolume() { + viewBinding.sbVolume.max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + viewBinding.sbVolume.progress = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val filter = IntentFilter(VOLUME_CHANGED_ACTION) + registerReceiverCompat(volumeReceiver, filter) + } + + private fun initCover() { + val playState = playerController.playState.value + viewBinding.albumCoverView.initNeedle(playState.isPlaying) + viewBinding.clAlbumCover.setOnClickListener { + switchCoverLrc(false) + } + } + + private fun initLrc() { + viewBinding.lrcView.setDraggable(true) { view, time -> + val playState = playerController.playState.value + if (playState.isPlaying || playState.isPausing) { + playerController.seekTo(time.toInt()) + if (playState.isPausing) { + playerController.playPause() + } + return@setDraggable true + } + return@setDraggable false + } + viewBinding.lrcView.setOnTapListener { view: LrcView?, x: Float, y: Float -> + switchCoverLrc(true) + } + } + + private fun initActions() { + viewBinding.ivLike.setOnClickListener { + lifecycleScope.launch { + val song = playerController.currentSong.value ?: return@launch + val res = likeSongProcessor.like(this@PlayingActivity, song.getSongId()) + if (res.isSuccess()) { + updateOnlineActionsState(song) + } else { + toast(res.msg) + } + } + } + viewBinding.ivDownload.setOnClickListener { + lifecycleScope.launch { + val song = playerController.currentSong.value ?: return@launch + val res = apiCall { + DiscoverApi.get() + .getSongUrl(song.getSongId(), ConfigPreferences.downloadSoundQuality) + } + if (res.isSuccessWithData() && res.getDataOrThrow().isNotEmpty()) { + val url = res.getDataOrThrow().first().url + LaunchUtils.launchBrowser(this@PlayingActivity, url) + } else { + toast(res.msg) + } + } + } + } + + private fun initPlayControl() { + lifecycleScope.launch { + playerController.playMode.collectLatest { playMode -> + viewBinding.ivMode.setImageLevel(playMode.value) + } + } + + val lp = viewBinding.navigationBarPlaceholder.layoutParams + lp.height = ImmersionBar.getNavigationBarHeight(this) + viewBinding.navigationBarPlaceholder.layoutParams = lp + + viewBinding.ivMode.setOnClickListener { + switchPlayMode() + } + viewBinding.ivPlay.setOnClickListener { + playerController.playPause() + } + viewBinding.ivPrev.setOnClickListener { + playerController.prev() + } + viewBinding.ivNext.setOnClickListener { + playerController.next() + } + viewBinding.ivPlaylist.setOnClickListener { + CurrentPlaylistFragment.newInstance() + .show(supportFragmentManager, CurrentPlaylistFragment.TAG) + } + viewBinding.sbProgress.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (abs(progress - lastProgress) >= DateUtils.SECOND_IN_MILLIS) { + viewBinding.tvCurrentTime.text = TimeUtils.formatMs(progress.toLong()) + lastProgress = progress + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + isDraggingProgress = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + seekBar ?: return + isDraggingProgress = false + val playState = playerController.playState.value + if (playState.isPlaying || playState.isPausing) { + val progress = seekBar.progress + playerController.seekTo(progress) + if (viewBinding.lrcView.hasLrc()) { + viewBinding.lrcView.updateTime(progress.toLong()) + } + } else { + seekBar.progress = 0 + } + } + }) + viewBinding.sbVolume.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + seekBar ?: return + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + seekBar.progress, + AudioManager.FLAG_REMOVE_SOUND_AND_VIBRATE + ) + } + }) + } + + private fun initData() { + playerController.currentSong.observe(this) { song -> + if (song != null) { + viewBinding.tvTitle.text = song.mediaMetadata.title + viewBinding.tvArtist.text = song.mediaMetadata.artist + viewBinding.sbProgress.max = song.mediaMetadata.getDuration().toInt() + viewBinding.sbProgress.progress = playerController.playProgress.value.toInt() + viewBinding.sbProgress.secondaryProgress = 0 + lastProgress = 0 + viewBinding.tvCurrentTime.text = + TimeUtils.formatMs(playerController.playProgress.value) + viewBinding.tvTotalTime.text = TimeUtils.formatMs(song.mediaMetadata.getDuration()) + updateCover(song) + updateLrc(song) + viewBinding.albumCoverView.reset() + val playState = playerController.playState.value + if (playState.isPlaying || playState.isPreparing) { + viewBinding.ivPlay.isSelected = true + viewBinding.albumCoverView.start() + } else { + viewBinding.ivPlay.isSelected = false + viewBinding.albumCoverView.pause() + } + updateOnlineActionsState(song) + } else { + finish() + } + } + + lifecycleScope.launch { + playerController.playState.collectLatest { playState -> + if (playState.isPlaying) { + viewBinding.ivPlay.isSelected = true + viewBinding.albumCoverView.start() + } else { + viewBinding.ivPlay.isSelected = false + viewBinding.albumCoverView.pause() + } + } + } + + lifecycleScope.launch { + playerController.playProgress.collectLatest { progress -> + if (isDraggingProgress.not()) { + viewBinding.sbProgress.progress = progress.toInt() + } + if (viewBinding.lrcView.hasLrc()) { + viewBinding.lrcView.updateTime(progress) + } + } + } + + lifecycleScope.launch { + playerController.bufferingPercent.collectLatest { percent -> + viewBinding.sbProgress.secondaryProgress = + viewBinding.sbProgress.max * percent / 100 + } + } + } + + private fun updateCover(song: MediaItem) { + viewBinding.albumCoverView.setCoverBitmap(defaultCoverBitmap) + viewBinding.ivPlayingBg.setImageResource(R.drawable.bg_playing_default) + ImageUtils.loadBitmap(song.mediaMetadata.artworkUri.toString()) { + if (it.isSuccessWithData()) { + val bitmap = it.getDataOrThrow() + viewBinding.albumCoverView.setCoverBitmap(bitmap) + Blurry.with(this).sampling(10).from(bitmap).into(viewBinding.ivPlayingBg) + } + } + } + + private fun updateLrc(song: MediaItem) { + loadLrcJob?.cancel() + loadLrcJob = null + val lrcPath = LrcCache.getLrcFilePath(song) + if (lrcPath?.isNotEmpty() == true) { + loadLrc(lrcPath) + return + } + viewBinding.lrcView.loadLrc("") + if (song.isLocal()) { + setLrcLabel("暂无歌词") + } else { + setLrcLabel("歌词加载中…") + loadLrcJob = lifecycleScope.launch { + kotlin.runCatching { + val lrcWrap = DiscoverApi.get().getLrc(song.getSongId()) + if (lrcWrap.code == 200 && lrcWrap.lrc.isValid()) { + lrcWrap.lrc + } else { + throw IllegalStateException("lrc is invalid") + } + }.onSuccess { + val file = LrcCache.saveLrcFile(song, it.lyric) + loadLrc(file.path) + }.onFailure { + Log.e(TAG, "load lrc error", it) + setLrcLabel("歌词加载失败") + } + } + } + } + + private fun loadLrc(path: String) { + val file = File(path) + viewBinding.lrcView.loadLrc(file) + } + + private fun setLrcLabel(label: String) { + viewBinding.lrcView.setLabel(label) + } + + private fun switchCoverLrc(showCover: Boolean) { + viewBinding.clAlbumCover.isVisible = showCover + viewBinding.lrcLayout.isVisible = showCover.not() + } + + private fun switchPlayMode() { + val mode = when (playerController.playMode.value) { + PlayMode.Loop -> PlayMode.Shuffle + PlayMode.Shuffle -> PlayMode.Single + PlayMode.Single -> PlayMode.Loop + } + toast(mode.nameRes) + playerController.setPlayMode(mode) + } + + private fun updateOnlineActionsState(song: MediaItem) { + viewBinding.llActions.isVisible = song.isLocal().not() + viewBinding.ivLike.isSelected = likeSongProcessor.isLiked(song.getSongId()) + } + + override fun getNavigationBarColor(): Int { + return R.color.black + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(volumeReceiver) + } + + private val volumeReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + viewBinding.sbVolume.progress = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + } + } + + companion object { + private const val TAG = "PlayingActivity" + private const val VOLUME_CHANGED_ACTION = "android.media.VOLUME_CHANGED_ACTION" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/playlist/CurrentPlaylistFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/main/playlist/CurrentPlaylistFragment.kt new file mode 100644 index 0000000..4beb7b5 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/playlist/CurrentPlaylistFragment.kt @@ -0,0 +1,145 @@ +package me.wcy.music.main.playlist + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.text.buildSpannedString +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.recyclerview.widget.LinearLayoutManager +import com.blankj.utilcode.util.ActivityUtils +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.databinding.FragmentCurrentPlaylistBinding +import me.wcy.music.main.playing.PlayingActivity +import me.wcy.music.service.PlayMode +import me.wcy.music.service.PlayerController +import me.wcy.radapter3.RAdapter +import top.wangchenyan.common.ext.getColorEx +import top.wangchenyan.common.ext.showConfirmDialog +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.widget.CustomSpan.appendStyle +import javax.inject.Inject + +/** + * + */ +@AndroidEntryPoint +class CurrentPlaylistFragment : BottomSheetDialogFragment() { + private val viewBinding by viewBindings() + private val adapter by lazy { RAdapter() } + private val layoutManager by lazy { LinearLayoutManager(requireContext()) } + + @Inject + lateinit var playerController: PlayerController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.BottomSheetDialogTheme) + } + + override fun getTheme(): Int { + return R.style.BottomSheetDialogTheme + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return viewBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initView() + initData() + } + + private fun initView() { + viewBinding.llPlayMode.setOnClickListener { + switchPlayMode() + } + viewBinding.btnClear.setOnClickListener { + showConfirmDialog(message = "确认清空播放列表?") { + playerController.clearPlaylist() + dismissAllowingStateLoss() + ActivityUtils.finishActivity(PlayingActivity::class.java) + } + } + + adapter.register( + CurrentPlaylistItemBinder( + playerController, + object : OnItemClickListener2 { + override fun onItemClick(item: MediaItem, position: Int) { + playerController.play(item.mediaId) + } + + override fun onMoreClick(item: MediaItem, position: Int) { + playerController.delete(item) + } + }) + ) + viewBinding.recyclerView.layoutManager = layoutManager + viewBinding.recyclerView.adapter = adapter + } + + private fun initData() { + lifecycleScope.launch { + playerController.playMode.collectLatest { playMode -> + viewBinding.ivMode.setImageLevel(playMode.value) + viewBinding.tvPlayMode.setText(playMode.nameRes) + } + } + + playerController.playlist.observe(this) { playlist -> + playlist ?: return@observe + val size = playlist.size + viewBinding.tvTitle.text = buildSpannedString { + append("当前播放") + if (size > 0) { + appendStyle( + "($size)", + color = context.getColorEx(R.color.common_text_h2_color), + isBold = true + ) + } + } + adapter.refresh(playlist) + } + playerController.currentSong.observe(this) { song -> + adapter.notifyDataSetChanged() + val playlist = playerController.playlist.value + if (playlist?.isNotEmpty() == true && song != null) { + val index = playlist.indexOfFirst { it.mediaId == song.mediaId } + if (index == 0) { + layoutManager.scrollToPosition(index) + } else if (index > 0) { + layoutManager.scrollToPosition(index - 1) + } + } + } + } + + private fun switchPlayMode() { + val mode = when (playerController.playMode.value) { + PlayMode.Loop -> PlayMode.Shuffle + PlayMode.Shuffle -> PlayMode.Single + PlayMode.Single -> PlayMode.Loop + } + playerController.setPlayMode(mode) + } + + companion object { + const val TAG = "CurrentPlaylistFragment" + fun newInstance(): CurrentPlaylistFragment { + return CurrentPlaylistFragment() + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/main/playlist/CurrentPlaylistItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/main/playlist/CurrentPlaylistItemBinder.kt new file mode 100644 index 0000000..5dc4ec3 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/main/playlist/CurrentPlaylistItemBinder.kt @@ -0,0 +1,30 @@ +package me.wcy.music.main.playlist + +import android.annotation.SuppressLint +import androidx.media3.common.MediaItem +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.databinding.ItemCurrentPlaylistBinding +import me.wcy.music.service.PlayerController +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class CurrentPlaylistItemBinder( + private val playerController: PlayerController, + private val listener: OnItemClickListener2 +) : + RItemBinder() { + @SuppressLint("SetTextI18n") + override fun onBind(viewBinding: ItemCurrentPlaylistBinding, item: MediaItem, position: Int) { + viewBinding.root.isSelected = (playerController.currentSong.value == item) + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.tvTitle.text = item.mediaMetadata.title + viewBinding.tvArtist.text = " · ${item.mediaMetadata.artist}" + viewBinding.ivDelete.setOnClickListener { + listener.onMoreClick(item, position) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/MineApi.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/MineApi.kt new file mode 100644 index 0000000..d715c25 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/MineApi.kt @@ -0,0 +1,87 @@ +package me.wcy.music.mine + +import me.wcy.music.discover.playlist.square.bean.PlaylistListData +import me.wcy.music.mine.collect.song.bean.CollectSongResult +import me.wcy.music.net.HttpClient +import me.wcy.music.service.likesong.bean.LikeSongListData +import me.wcy.music.storage.preference.ConfigPreferences +import retrofit2.Retrofit +import retrofit2.http.POST +import retrofit2.http.Query +import top.wangchenyan.common.net.NetResult +import top.wangchenyan.common.net.gson.GsonConverterFactory +import top.wangchenyan.common.utils.GsonUtils +import top.wangchenyan.common.utils.ServerTime + +/** + * + */ +interface MineApi { + + @POST("user/playlist") + suspend fun getUserPlaylist( + @Query("uid") uid: Long, + @Query("limit") limit: Int = 1000, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): PlaylistListData + + /** + * 收藏/取消收藏歌单 + * @param id 歌单 id + * @param t 类型,1:收藏,2:取消收藏 + */ + @POST("playlist/subscribe") + suspend fun collectPlaylist( + @Query("id") id: Long, + @Query("t") t: Int, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): NetResult + + /** + * 对歌单添加歌曲 + * @param op 从歌单增加单曲为 add, 删除为 del + * @param pid 歌单 id + * @param tracks 歌曲 id,可多个,用逗号隔开 + */ + @POST("playlist/tracks") + suspend fun collectSong( + @Query("pid") pid: Long, + @Query("tracks") tracks: String, + @Query("op") op: String = "add", + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): CollectSongResult + + /** + * 喜欢音乐 + * @param id 歌曲 id + * @param like 默认为 true 即喜欢 , 若传 false, 则取消喜欢 + */ + @POST("like") + suspend fun likeSong( + @Query("id") id: Long, + @Query("like") like: Boolean = true, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): NetResult + + /** + * 喜欢音乐列表 + */ + @POST("likelist") + suspend fun getMyLikeSongList( + @Query("uid") uid: Long, + @Query("timestamp") timestamp: Long = ServerTime.currentTimeMillis() + ): LikeSongListData + + companion object { + private val api: MineApi by lazy { + val retrofit = Retrofit.Builder() + .baseUrl(ConfigPreferences.apiDomain) + .addConverterFactory(GsonConverterFactory.create(GsonUtils.gson, true)) + .client(HttpClient.okHttpClient) + .build() + retrofit.create(MineApi::class.java) + } + + fun get(): MineApi = api + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/CollectSongFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/CollectSongFragment.kt new file mode 100644 index 0000000..cebfb54 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/CollectSongFragment.kt @@ -0,0 +1,115 @@ +package me.wcy.music.mine.collect.song + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.blankj.utilcode.util.SizeUtils +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.FragmentCollectSongBinding +import me.wcy.music.mine.playlist.UserPlaylistItemBinder +import me.wcy.radapter3.RAdapter +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.widget.decoration.SpacingDecoration + +/** + * + */ +@AndroidEntryPoint +class CollectSongFragment : BottomSheetDialogFragment() { + private val viewBinding by viewBindings() + private val viewModel: CollectSongViewModel by viewModels() + private val adapter by lazy { RAdapter() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.BottomSheetDialogTheme) + } + + override fun getTheme(): Int { + return R.style.BottomSheetDialogTheme + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return viewBinding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val songId = arguments?.getLong("song_id") ?: 0 + if (songId <= 0) { + toast("参数错误") + dismissAllowingStateLoss() + return + } + + viewModel.songId = songId + + initView() + initData() + lifecycleScope.launch { + viewModel.getMyPlayList() + } + } + + private fun initView() { + adapter.register( + UserPlaylistItemBinder( + true, + object : UserPlaylistItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData) { + collectSong(item.id) + } + + override fun onMoreClick(item: PlaylistData) { + } + }) + ) + val spacingDecoration = SpacingDecoration(SizeUtils.dp2px(10f)) + viewBinding.recyclerView.addItemDecoration(spacingDecoration) + viewBinding.recyclerView.adapter = adapter + } + + private fun initData() { + lifecycleScope.launch { + viewModel.myPlaylists.collectLatest { + adapter.refresh(it) + } + } + } + + private fun collectSong(pid: Long) { + lifecycleScope.launch { + val res = viewModel.collectSong(pid) + if (res.isSuccess()) { + toast("操作成功") + dismissAllowingStateLoss() + } else { + toast(res.msg) + } + } + } + + companion object { + const val TAG = "CollectSongFragment" + + fun newInstance(songId: Long): CollectSongFragment { + return CollectSongFragment().apply { + arguments = bundleOf("song_id" to songId) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/CollectSongViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/CollectSongViewModel.kt new file mode 100644 index 0000000..5e354f9 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/CollectSongViewModel.kt @@ -0,0 +1,55 @@ +package me.wcy.music.mine.collect.song + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import me.wcy.music.account.service.UserService +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.mine.MineApi +import top.wangchenyan.common.model.CommonResult +import javax.inject.Inject + +/** + * + */ +@HiltViewModel +class CollectSongViewModel @Inject constructor() : ViewModel() { + private val _myPlaylists = MutableStateFlow>(emptyList()) + val myPlaylists = _myPlaylists + + var songId: Long = 0 + + @Inject + lateinit var userService: UserService + + suspend fun getMyPlayList(): CommonResult> { + val uid = userService.profile.value?.userId ?: 0 + val res = kotlin.runCatching { + MineApi.get().getUserPlaylist(uid) + } + val playlistData = res.getOrNull() + return if (playlistData?.code == 200) { + val list = playlistData.playlists.filter { it.userId == uid } + _myPlaylists.value = list + CommonResult.success(list) + } else { + CommonResult.fail(playlistData?.code ?: -1) + } + } + + suspend fun collectSong(pid: Long): CommonResult { + val res = kotlin.runCatching { + MineApi.get().collectSong(pid, songId.toString()) + } + return if (res.isSuccess) { + val body = res.getOrThrow().body + if (body.code == 200) { + CommonResult.success(Unit) + } else { + CommonResult.fail(body.code, body.message) + } + } else { + CommonResult.fail(msg = res.exceptionOrNull()?.message) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/bean/CollectSongResult.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/bean/CollectSongResult.kt new file mode 100644 index 0000000..60ebaae --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/collect/song/bean/CollectSongResult.kt @@ -0,0 +1,20 @@ +package me.wcy.music.mine.collect.song.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class CollectSongResult( + @SerializedName("status") + val status: Int = 0, + @SerializedName("body") + val body: Body = Body(), +) { + data class Body( + @SerializedName("code") + val code: Int = 0, + @SerializedName("message") + val message: String = "", + ) +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/home/MineFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/home/MineFragment.kt new file mode 100644 index 0000000..d933cf4 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/home/MineFragment.kt @@ -0,0 +1,186 @@ +package me.wcy.music.mine.home + +import android.annotation.SuppressLint +import android.view.View +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.blankj.utilcode.util.SizeUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.account.service.UserService +import me.wcy.music.common.ApiDomainDialog +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentMineBinding +import me.wcy.music.main.MainActivity +import me.wcy.music.mine.home.viewmodel.MineViewModel +import me.wcy.music.mine.playlist.UserPlaylistItemBinder +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter +import top.wangchenyan.common.ext.loadAvatar +import top.wangchenyan.common.ext.toast +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.widget.decoration.SpacingDecoration +import top.wangchenyan.common.widget.dialog.BottomItemsDialogBuilder +import javax.inject.Inject + +/** + * + */ +@AndroidEntryPoint +class MineFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val viewModel by viewModels() + + @Inject + lateinit var userService: UserService + + override fun getRootView(): View { + return viewBinding.root + } + + override fun onLazyCreate() { + super.onLazyCreate() + + initTitle() + initProfile() + initLocalMusic() + initPlaylist() + viewModel.updatePlaylistFromCache() + } + + override fun onResume() { + super.onResume() + viewModel.updatePlaylist() + } + + private fun initTitle() { + getTitleLayout()?.run { + addImageMenu( + R.drawable.ic_menu, + isDayNight = true, + isLeft = true + ).setOnClickListener { + val activity = requireActivity() + if (activity is MainActivity) { + activity.openDrawer() + } + } + addImageMenu( + R.drawable.ic_menu_search, + isDayNight = true, + isLeft = false + ).setOnClickListener { + if (ApiDomainDialog.checkApiDomain(requireContext())) { + CRouter.with(requireActivity()).url(RoutePath.SEARCH).start() + } + } + } + } + + private fun initProfile() { + lifecycleScope.launch { + userService.profile.collectLatest { profile -> + viewBinding.ivAvatar.loadAvatar(profile?.avatarUrl) + viewBinding.tvNickName.text = profile?.nickname + viewBinding.flProfile.setOnClickListener { + if (ApiDomainDialog.checkApiDomain(requireActivity())) { + if (userService.isLogin().not()) { + CRouter.with(requireActivity()) + .url(RoutePath.LOGIN) + .start() + } + } + } + } + } + } + + private fun initLocalMusic() { + viewBinding.localMusic.setOnClickListener { + CRouter.with().url(RoutePath.LOCAL_SONG).start() + } + } + + @SuppressLint("SetTextI18n") + private fun initPlaylist() { + val likePlaylistAdapter = RAdapter().apply { + register(UserPlaylistItemBinder(true, ItemClickListener(true, isLike = true))) + } + val myPlaylistAdapter = RAdapter().apply { + register(UserPlaylistItemBinder(true, ItemClickListener(true, isLike = false))) + } + val collectPlaylistAdapter = RAdapter().apply { + register(UserPlaylistItemBinder(false, ItemClickListener(false, isLike = false))) + } + + val spacingDecoration = SpacingDecoration(SizeUtils.dp2px(10f)) + viewBinding.rvLikePlaylist.adapter = likePlaylistAdapter + viewBinding.rvMyPlaylist.addItemDecoration(spacingDecoration) + viewBinding.rvMyPlaylist.adapter = myPlaylistAdapter + viewBinding.rvCollectPlaylist.addItemDecoration(spacingDecoration) + viewBinding.rvCollectPlaylist.adapter = collectPlaylistAdapter + + val updateVisible = { + viewBinding.llLikePlaylist.isVisible = viewModel.likePlaylist.value != null + viewBinding.llMyPlaylist.isVisible = viewModel.myPlaylists.value.isNotEmpty() + viewBinding.llCollectPlaylist.isVisible = viewModel.collectPlaylists.value.isNotEmpty() + } + + lifecycleScope.launch { + viewModel.likePlaylist.collectLatest { likePlaylist -> + updateVisible() + if (likePlaylist != null) { + likePlaylistAdapter.refresh(listOf(likePlaylist)) + } + } + } + lifecycleScope.launch { + viewModel.myPlaylists.collectLatest { myPlaylists -> + updateVisible() + viewBinding.tvMyPlaylist.text = "创建歌单(${myPlaylists.size})" + myPlaylistAdapter.refresh(myPlaylists) + } + } + lifecycleScope.launch { + viewModel.collectPlaylists.collectLatest { collectPlaylists -> + updateVisible() + viewBinding.tvCollectPlaylist.text = "收藏歌单(${collectPlaylists.size})" + collectPlaylistAdapter.refresh(collectPlaylists) + } + } + } + + inner class ItemClickListener(private val isMine: Boolean, private val isLike: Boolean) : + UserPlaylistItemBinder.OnItemClickListener { + override fun onItemClick(item: PlaylistData) { + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", item.id) + .extra("realtime_data", isMine) + .extra("is_like", isLike) + .start() + } + + override fun onMoreClick(item: PlaylistData) { + BottomItemsDialogBuilder(requireActivity()) + .items(listOf("删除")) + .onClickListener { dialog, which -> + lifecycleScope.launch { + showLoading() + val res = viewModel.removeCollect(item.id) + dismissLoading() + if (res.isSuccess().not()) { + toast(res.msg) + } + } + } + .build() + .show() + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/home/viewmodel/MineViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/home/viewmodel/MineViewModel.kt new file mode 100644 index 0000000..31fa676 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/home/viewmodel/MineViewModel.kt @@ -0,0 +1,105 @@ +package me.wcy.music.mine.home.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.account.service.UserService +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.mine.MineApi +import me.wcy.music.net.NetCache +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.model.CommonResult +import top.wangchenyan.common.net.apiCall +import javax.inject.Inject + +/** + * + */ +@HiltViewModel +class MineViewModel @Inject constructor() : ViewModel() { + private val _likePlaylist = MutableStateFlow(null) + val likePlaylist = _likePlaylist.toUnMutable() + private val _myPlaylists = MutableStateFlow>(emptyList()) + val myPlaylists = _myPlaylists + private val _collectPlaylists = MutableStateFlow>(emptyList()) + val collectPlaylists = _collectPlaylists + + @Inject + lateinit var userService: UserService + + private var updateJob: Job? = null + + init { + viewModelScope.launch { + userService.profile.collectLatest { profile -> + if (profile != null) { + updatePlaylist(profile.userId) + } else { + _likePlaylist.value = null + _myPlaylists.value = emptyList() + _collectPlaylists.value = emptyList() + } + } + } + } + + fun updatePlaylistFromCache() { + viewModelScope.launch { + if (userService.isLogin()) { + val uid = userService.profile.value?.userId ?: return@launch + val cacheList = NetCache.userCache.getJsonArray(CACHE_KEY, PlaylistData::class.java) + ?: return@launch + notifyPlaylist(uid, cacheList) + } + } + } + + fun updatePlaylist() { + if (userService.isLogin()) { + val uid = userService.profile.value?.userId ?: return + updatePlaylist(uid) + } + } + + private fun updatePlaylist(uid: Long) { + updateJob?.cancel() + updateJob = viewModelScope.launch { + val res = kotlin.runCatching { + MineApi.get().getUserPlaylist(uid) + } + if (res.getOrNull()?.code == 200) { + val list = res.getOrThrow().playlists + notifyPlaylist(uid, list) + NetCache.userCache.putJson(CACHE_KEY, list) + } + } + } + + private fun notifyPlaylist(uid: Long, list: List) { + val mineList = list.filter { it.userId == uid } + _likePlaylist.value = mineList.firstOrNull() + _myPlaylists.value = mineList.takeLast((mineList.size - 1).coerceAtLeast(0)) + _collectPlaylists.value = list.filter { it.userId != uid } + } + + suspend fun removeCollect(id: Long): CommonResult { + val res = apiCall { MineApi.get().collectPlaylist(id, 2) } + return if (res.isSuccess()) { + val list = _collectPlaylists.value + _collectPlaylists.value = list.toMutableList().apply { + removeAll { it.id == id } + } + CommonResult.success(Unit) + } else { + CommonResult.fail(res.code, res.msg) + } + } + + companion object { + private const val CACHE_KEY = "my_playlist" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalMusicFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalMusicFragment.kt new file mode 100644 index 0000000..588447a --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalMusicFragment.kt @@ -0,0 +1,123 @@ +package me.wcy.music.mine.local + +import android.view.View +import androidx.lifecycle.lifecycleScope +import com.blankj.utilcode.util.ConvertUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.wcy.music.R +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.dialog.songmenu.SimpleMenuItem +import me.wcy.music.common.dialog.songmenu.SongMoreMenuDialog +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentLocalMusicBinding +import me.wcy.music.service.PlayerController +import me.wcy.music.storage.db.entity.SongEntity +import me.wcy.music.utils.TimeUtils +import me.wcy.music.utils.toMediaItem +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter +import me.wcy.router.annotation.Route +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.permission.Permissioner +import javax.inject.Inject + +/** + * + */ +@Route(RoutePath.LOCAL_SONG) +@AndroidEntryPoint +class LocalMusicFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val localMusicLoader by lazy { + LocalMusicLoader() + } + private val adapter by lazy { + RAdapter() + } + + @Inject + lateinit var playerController: PlayerController + + override fun getRootView(): View { + return viewBinding.root + } + + override fun isUseLoadSir(): Boolean { + return true + } + + override fun getLoadSirTarget(): View { + return viewBinding.content + } + + override fun onReload() { + super.onReload() + loadData() + } + + override fun onLazyCreate() { + super.onLazyCreate() + + adapter.register(LocalSongItemBinder(object : OnItemClickListener2 { + override fun onItemClick(item: SongEntity, position: Int) { + val mediaList = adapter.getDataList().map { it.toMediaItem() } + playerController.replaceAll(mediaList, mediaList[position]) + CRouter.with(requireContext()).url(RoutePath.PLAYING).start() + } + + override fun onMoreClick(item: SongEntity, position: Int) { + SongMoreMenuDialog(requireActivity(), item) + .setItems( + listOf( + SimpleMenuItem("文件名称: ${item.fileName}"), + SimpleMenuItem("播放时长: ${TimeUtils.formatMs(item.duration)}"), + SimpleMenuItem( + "文件大小: ${ConvertUtils.byte2FitMemorySize(item.fileSize)}" + ), + SimpleMenuItem("文件路径: ${item.path}") + ) + ) + .show() + } + })) + viewBinding.recyclerView.adapter = adapter + + viewBinding.tvPlayAll.setOnClickListener { + val mediaList = adapter.getDataList().map { it.toMediaItem() } + playerController.replaceAll(mediaList, mediaList.first()) + CRouter.with(requireContext()).url(RoutePath.PLAYING).start() + } + + loadData() + } + + private fun loadData() { + showLoadSirLoading() + Permissioner.requestStoragePermission(requireContext()) { granted, shouldRationale -> + if (granted) { + lifecycleScope.launch { + val songList = withContext(Dispatchers.Default) { + localMusicLoader.load(requireContext()) + } + if (songList.isNotEmpty()) { + showLoadSirSuccess() + viewBinding.tvPlayAll.text = "播放全部(${songList.size})" + adapter.refresh(songList) + } else { + showLoadSirEmpty(getString(R.string.no_local_music)) + } + } + } else { + showLoadSirError(getString(R.string.no_permission_storage)) + } + } + } + + override fun getNavigationBarColor(): Int { + return R.color.play_bar_bg + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalMusicLoader.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalMusicLoader.kt new file mode 100644 index 0000000..ec49356 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalMusicLoader.kt @@ -0,0 +1,103 @@ +package me.wcy.music.mine.local + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.provider.MediaStore +import me.wcy.music.storage.db.entity.SongEntity +import me.wcy.music.storage.preference.ConfigPreferences + +/** + * + */ +class LocalMusicLoader { + private val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.IS_MUSIC, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.DATA, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.DURATION, + ) + private val sortOrder = "${MediaStore.Audio.Media.DATE_MODIFIED} DESC" + + fun load(context: Context): List { + val result = mutableListOf() + val query = context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + null, + null, + sortOrder + ) + + query?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val albumIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) + val displayNameColumn = + cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val isMusicColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.IS_MUSIC) + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + + val filterTime = ConfigPreferences.filterTime.toLong() * 1000 + val filterSize = ConfigPreferences.filterSize.toLong() * 1024 + + while (cursor.moveToNext()) { + val isMusic = cursor.getInt(isMusicColumn) + if (isMusic == 0) { + continue + } + val duration = cursor.getLong(durationColumn) + if (duration < filterTime) { + continue + } + val fileSize = cursor.getLong(sizeColumn) + if (fileSize < filterSize) { + continue + } + val id = cursor.getLong(idColumn) + val title = cursor.getString(titleColumn) + val artist = cursor.getString(artistColumn) + val album = cursor.getString(albumColumn) + val albumId = cursor.getLong(albumIdColumn) + + val albumCover = ContentUris.withAppendedId( + MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, + albumId + ) + + val uri = + Uri.withAppendedPath(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id.toString()) + + val path = cursor.getString(dataColumn) + val fileName = cursor.getString(displayNameColumn) + + val entity = SongEntity( + type = SongEntity.LOCAL, + songId = id, + title = title, + artist = artist, + album = album, + albumId = albumId, + albumCover = albumCover.toString(), + duration = duration, + path = path, + fileName = fileName, + fileSize = fileSize, + ) + result.add(entity) + } + } + + return result + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalSongItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalSongItemBinder.kt new file mode 100644 index 0000000..2038613 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/local/LocalSongItemBinder.kt @@ -0,0 +1,26 @@ +package me.wcy.music.mine.local + +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.databinding.ItemLocalSongBinding +import me.wcy.music.storage.db.entity.SongEntity +import me.wcy.music.utils.MusicUtils +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class LocalSongItemBinder( + private val listener: OnItemClickListener2 +) : RItemBinder() { + + override fun onBind(viewBinding: ItemLocalSongBinding, item: SongEntity, position: Int) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.ivMore.setOnClickListener { + listener.onMoreClick(item, position) + } + viewBinding.tvTitle.text = item.title + viewBinding.tvArtist.text = MusicUtils.getArtistAndAlbum(item.artist, item.album) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/mine/playlist/UserPlaylistItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/mine/playlist/UserPlaylistItemBinder.kt new file mode 100644 index 0000000..214590b --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/mine/playlist/UserPlaylistItemBinder.kt @@ -0,0 +1,39 @@ +package me.wcy.music.mine.playlist + +import androidx.core.view.isVisible +import com.blankj.utilcode.util.SizeUtils +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.ItemUserPlaylistBinding +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class UserPlaylistItemBinder( + private val isMine: Boolean, + private val listener: OnItemClickListener +) : RItemBinder() { + + override fun onBind(viewBinding: ItemUserPlaylistBinding, item: PlaylistData, position: Int) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item) + } + viewBinding.ivCover.loadCover(item.getSmallCover(), SizeUtils.dp2px(4f)) + viewBinding.tvName.text = item.name + viewBinding.tvCount.text = if (isMine) { + "${item.trackCount}首" + } else { + "${item.trackCount}首, by ${item.creator.nickname}" + } + viewBinding.ivMore.isVisible = isMine.not() + viewBinding.ivMore.setOnClickListener { + listener.onMoreClick(item) + } + } + + interface OnItemClickListener { + fun onItemClick(item: PlaylistData) + fun onMoreClick(item: PlaylistData) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/HeaderInterceptor.kt b/AAmusic/app/src/main/java/me/wcy/music/net/HeaderInterceptor.kt new file mode 100644 index 0000000..14a9108 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/HeaderInterceptor.kt @@ -0,0 +1,65 @@ +package me.wcy.music.net + +import android.util.Log +import com.blankj.utilcode.util.GsonUtils +import com.google.gson.JsonObject +import top.wangchenyan.common.CommonApp +import me.wcy.music.account.service.UserServiceModule.Companion.userService +import me.wcy.music.net.NetUtils.toJsonBody +import okhttp3.Interceptor +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.Buffer +import okio.IOException + +/** + * Created by wcy on 2018/7/15. + */ +class HeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val cookie = CommonApp.app.userService().getCookie() + if (cookie.isNotEmpty() && request.method == "POST") { + val body = request.body + if (body == null || body.contentLength() <= 0) { + val newBody = mapOf("cookie" to cookie).toJsonBody() + val newRequest = request.newBuilder() + .post(newBody) + .build() + return chain.proceed(newRequest) + } else if (body.contentType().toString() + .contains(NetUtils.CONTENT_TYPE_JSON.toString()) + ) { + val bodyString = try { + var oldBodyString = body.bodyToString() + if (oldBodyString.isEmpty()) { + oldBodyString = "{}" + } + val jsonObject = GsonUtils.fromJson(oldBodyString, JsonObject::class.java) + jsonObject.addProperty("cookie", cookie) + jsonObject.toString() + } catch (e: Exception) { + Log.e(TAG, "add cookie to body error") + "{}" + } + val newRequest = request.newBuilder() + .post(bodyString.toRequestBody(NetUtils.CONTENT_TYPE_JSON)) + .build() + return chain.proceed(newRequest) + } + } + return chain.proceed(request) + } + + @Throws(IOException::class) + private fun RequestBody.bodyToString(): String { + val buffer = Buffer() + this.writeTo(buffer) + return buffer.readUtf8() + } + + companion object { + private const val TAG = "HeaderInterceptor" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/HttpClient.kt b/AAmusic/app/src/main/java/me/wcy/music/net/HttpClient.kt new file mode 100644 index 0000000..14547c0 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/HttpClient.kt @@ -0,0 +1,40 @@ +package me.wcy.music.net + +import android.util.Log +import com.ihsanbal.logging.Level +import com.ihsanbal.logging.LoggingInterceptor +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.utils.ServerTime +import me.wcy.music.consts.FilePath +import okhttp3.Cache +import okhttp3.OkHttpClient +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * + */ +object HttpClient { + + val okHttpClient: OkHttpClient by lazy { + val builder = OkHttpClient.Builder() + .connectTimeout(125, TimeUnit.SECONDS) + .readTimeout(125, TimeUnit.SECONDS) + .writeTimeout(125, TimeUnit.SECONDS) + // 忽略host验证 + .hostnameVerifier { hostname, session -> true } + .cache(Cache(File(FilePath.httpCache), 10 * 1024 * 1024)) + .addInterceptor(HeaderInterceptor()) + .addInterceptor(ServerTime) + if (CommonApp.test) { + builder.addInterceptor( + LoggingInterceptor.Builder() + .tag("MusicHttp") + .setLevel(Level.BASIC) + .log(Log.WARN) + .build() + ) + } + builder.build() + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/NetCache.kt b/AAmusic/app/src/main/java/me/wcy/music/net/NetCache.kt new file mode 100644 index 0000000..c4d720d --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/NetCache.kt @@ -0,0 +1,70 @@ +package me.wcy.music.net + +import com.blankj.utilcode.util.CacheDiskUtils +import com.blankj.utilcode.util.GsonUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * + */ +class NetCache(name: String) { + private val cache by lazy { + CacheDiskUtils.getInstance(name) + } + + suspend fun getString(key: String): String { + return withContext(Dispatchers.IO) { + cache.getString(key, "") + } + } + + suspend fun getJsonObject(key: String, clazz: Class): T? { + return withContext(Dispatchers.IO) { + val json = getString(key) + kotlin.runCatching { + GsonUtils.fromJson(json, clazz) + }.getOrNull() + } + } + + suspend fun getJsonArray(key: String, clazz: Class): List? { + return withContext(Dispatchers.IO) { + val json = getString(key) + top.wangchenyan.common.utils.GsonUtils.fromJsonList(json, clazz) + } + } + + suspend fun putString(key: String, value: String) { + withContext(Dispatchers.IO) { + cache.put(key, value) + } + } + + suspend fun putJson(key: String, json: Any) { + withContext(Dispatchers.IO) { + cache.put(key, GsonUtils.toJson(json)) + } + } + + suspend fun remove(key: String) { + withContext(Dispatchers.IO) { + cache.remove(key) + } + } + + suspend fun clear() { + withContext(Dispatchers.IO) { + cache.clear() + } + } + + companion object { + val userCache by lazy { + NetCache("net/user") + } + val globalCache by lazy { + NetCache("net/global") + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/NetUtils.kt b/AAmusic/app/src/main/java/me/wcy/music/net/NetUtils.kt new file mode 100644 index 0000000..1e7944e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/NetUtils.kt @@ -0,0 +1,46 @@ +package me.wcy.music.net + +import com.blankj.utilcode.util.GsonUtils +import com.google.gson.JsonObject +import top.wangchenyan.common.model.CommonResult +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.HttpException + +/** + * + */ +object NetUtils { + val CONTENT_TYPE_JSON = "application/json".toMediaType() + + fun Map.toJsonBody(): RequestBody { + return GsonUtils.toJson(this).toRequestBody(CONTENT_TYPE_JSON) + } + + fun parseErrorResponse( + exception: Throwable?, + codeField: String = "code", + msgField: String = "message" + ): CommonResult { + var code = -1 + var msg = exception?.message + if (exception is HttpException) { + code = exception.code() + msg = exception.message() + val body = exception.response()?.errorBody()?.string().orEmpty() + if (body.isNotEmpty()) { + kotlin.runCatching { + val json = GsonUtils.fromJson(body, JsonObject::class.java) + if (json.has(codeField)) { + code = json.get(codeField).asInt + } + if (json.has(msgField)) { + msg = json.get(msgField).asString + } + } + } + } + return CommonResult.fail(code, msg) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/datasource/MusicDataSource.java b/AAmusic/app/src/main/java/me/wcy/music/net/datasource/MusicDataSource.java new file mode 100644 index 0000000..b41cd1a --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/datasource/MusicDataSource.java @@ -0,0 +1,438 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.wcy.music.net.datasource; + +import static me.wcy.music.utils.ModelExKt.SCHEME_NETEASE; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.Nullable; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.Log; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.AssetDataSource; +import androidx.media3.datasource.ContentDataSource; +import androidx.media3.datasource.DataSchemeDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.FileDataSource; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.RawResourceDataSource; +import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.UdpDataSource; + +//import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.firebase.crashlytics.buildtools.reloc.com.google.errorprone.annotations.CanIgnoreReturnValue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import me.wcy.music.net.HttpClient; +import okhttp3.Call; + +/** + *

+ * A copy of {@link DefaultDataSource} which support get real url when play. + *

+ *

+ * A {@link DataSource} that supports multiple URI schemes. The supported schemes are: + * + *

    + *
  • {@code file}: For fetching data from a local file (e.g. {@code + * file:///path/to/media/media.mp4}, or just {@code /path/to/media/media.mp4} because the + * implementation assumes that a URI without a scheme is a local file URI). + *
  • {@code asset}: For fetching data from an asset in the application's APK (e.g. {@code + * asset:///media.mp4}). + *
  • {@code rawresource}: For fetching data from a raw resource in the application's APK + * (e.g. {@code rawresource:///resourceId}, where {@code rawResourceId} is the integer + * identifier of the raw resource). + *
  • {@code android.resource}: For fetching data in the application's APK (e.g. {@code + * android.resource:///resourceId} or {@code android.resource://resourceType/resourceName}). + * See {@link RawResourceDataSource} for more information about the URI form. + *
  • {@code content}: For fetching data from a content URI (e.g. {@code + * content://authority/path/123}). + *
  • {@code rtmp}: For fetching data over RTMP. Only supported if the project using + * ExoPlayer has an explicit dependency on ExoPlayer's RTMP extension. + *
  • {@code data}: For parsing data inlined in the URI as defined in RFC 2397. + *
  • {@code udp}: For fetching data over UDP (e.g. {@code udp://something.com/media}). + *
  • {@code http(s)}: For fetching data over HTTP and HTTPS (e.g. {@code + * https://www.something.com/media.mp4}), if constructed using {@link + * #MusicDataSource(Context, String, boolean)}, or any other schemes supported by a base + * data source if constructed using {@link #MusicDataSource(Context, DataSource)}. + *
+ */ +@UnstableApi +public final class MusicDataSource implements DataSource { + + /** + * {@link DataSource.Factory} for {@link MusicDataSource} instances. + */ + public static final class Factory implements DataSource.Factory { + + private final Context context; + private final DataSource.Factory baseDataSourceFactory; + @Nullable + private TransferListener transferListener; + + /** + * Creates an instance. + * + * @param context A context. + */ + public Factory(Context context) { + this(context, new DefaultHttpDataSource.Factory()); + } + + /** + * Creates an instance. + * + * @param context A context. + * @param baseDataSourceFactory The {@link DataSource.Factory} to be used to create base {@link + * DataSource DataSources} for {@link MusicDataSource} instances. The base {@link + * DataSource} is normally an {@link HttpDataSource}, and is responsible for fetching data + * over HTTP and HTTPS, as well as any other URI schemes not otherwise supported by {@link + * MusicDataSource}. + */ + public Factory(Context context, DataSource.Factory baseDataSourceFactory) { + this.context = context.getApplicationContext(); + this.baseDataSourceFactory = baseDataSourceFactory; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link DataSource#addTransferListener(TransferListener)}. + * + * @param transferListener The listener that will be used. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setTransferListener(@Nullable TransferListener transferListener) { + this.transferListener = transferListener; + return this; + } + + @UnstableApi + @Override + public MusicDataSource createDataSource() { + MusicDataSource dataSource = + new MusicDataSource(context, baseDataSourceFactory.createDataSource()); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + private static final String TAG = "DefaultDataSource"; + + private static final String SCHEME_ASSET = "asset"; + private static final String SCHEME_CONTENT = "content"; + private static final String SCHEME_RTMP = "rtmp"; + private static final String SCHEME_UDP = "udp"; + private static final String SCHEME_DATA = DataSchemeDataSource.SCHEME_DATA; + + @SuppressWarnings("deprecation") // Detecting deprecated scheme. + private static final String SCHEME_RAW = RawResourceDataSource.RAW_RESOURCE_SCHEME; + + private static final String SCHEME_ANDROID_RESOURCE = ContentResolver.SCHEME_ANDROID_RESOURCE; + + private final Context context; + private final List transferListeners; + private final DataSource baseDataSource; + + // Lazily initialized. + @Nullable + private DataSource fileDataSource; + @Nullable + private DataSource assetDataSource; + @Nullable + private DataSource contentDataSource; + @Nullable + private DataSource rtmpDataSource; + @Nullable + private DataSource udpDataSource; + @Nullable + private DataSource dataSchemeDataSource; + @Nullable + private DataSource rawResourceDataSource; + @Nullable + private DataSource neteaseDataSource; + + @Nullable + private DataSource dataSource; + + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + * @param allowCrossProtocolRedirects Whether to allow cross-protocol redirects. + */ + @UnstableApi + public MusicDataSource(Context context, boolean allowCrossProtocolRedirects) { + this( + context, + /* userAgent= */ null, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + allowCrossProtocolRedirects); + } + + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + * @param userAgent The user agent that will be used when requesting remote data, or {@code null} + * to use the default user agent of the underlying platform. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled when fetching remote data. + */ + @UnstableApi + public MusicDataSource( + Context context, @Nullable String userAgent, boolean allowCrossProtocolRedirects) { + this( + context, + userAgent, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + allowCrossProtocolRedirects); + } + + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + * @param userAgent The user agent that will be used when requesting remote data, or {@code null} + * to use the default user agent of the underlying platform. + * @param connectTimeoutMillis The connection timeout that should be used when requesting remote + * data, in milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param readTimeoutMillis The read timeout that should be used when requesting remote data, in + * milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled when fetching remote data. + */ + @UnstableApi + public MusicDataSource( + Context context, + @Nullable String userAgent, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects) { + this( + context, + new DefaultHttpDataSource.Factory() + .setUserAgent(userAgent) + .setConnectTimeoutMs(connectTimeoutMillis) + .setReadTimeoutMs(readTimeoutMillis) + .setAllowCrossProtocolRedirects(allowCrossProtocolRedirects) + .createDataSource()); + } + + /** + * Constructs a new instance that delegates to a provided {@link DataSource} for URI schemes other + * than file, asset and content. + * + * @param context A context. + * @param baseDataSource A {@link DataSource} to use for URI schemes other than file, asset and + * content. This {@link DataSource} should normally support at least http(s). + */ + @UnstableApi + public MusicDataSource(Context context, DataSource baseDataSource) { + this.context = context.getApplicationContext(); + this.baseDataSource = Assertions.checkNotNull(baseDataSource); + transferListeners = new ArrayList<>(); + } + + @UnstableApi + @Override + public void addTransferListener(TransferListener transferListener) { + Assertions.checkNotNull(transferListener); + baseDataSource.addTransferListener(transferListener); + transferListeners.add(transferListener); + maybeAddListenerToDataSource(fileDataSource, transferListener); + maybeAddListenerToDataSource(assetDataSource, transferListener); + maybeAddListenerToDataSource(contentDataSource, transferListener); + maybeAddListenerToDataSource(rtmpDataSource, transferListener); + maybeAddListenerToDataSource(udpDataSource, transferListener); + maybeAddListenerToDataSource(dataSchemeDataSource, transferListener); + maybeAddListenerToDataSource(rawResourceDataSource, transferListener); + } + + @UnstableApi + @Override + public long open(DataSpec dataSpec) throws IOException { + Assertions.checkState(dataSource == null); + // Choose the correct source for the scheme. + String scheme = dataSpec.uri.getScheme(); + if (Util.isLocalFileUri(dataSpec.uri)) { + String uriPath = dataSpec.uri.getPath(); + if (uriPath != null && uriPath.startsWith("/android_asset/")) { + dataSource = getAssetDataSource(); + } else { + dataSource = getFileDataSource(); + } + } else if (SCHEME_ASSET.equals(scheme)) { + dataSource = getAssetDataSource(); + } else if (SCHEME_CONTENT.equals(scheme)) { + dataSource = getContentDataSource(); + } else if (SCHEME_RTMP.equals(scheme)) { + dataSource = getRtmpDataSource(); + } else if (SCHEME_UDP.equals(scheme)) { + dataSource = getUdpDataSource(); + } else if (SCHEME_DATA.equals(scheme)) { + dataSource = getDataSchemeDataSource(); + } else if (SCHEME_RAW.equals(scheme) || SCHEME_ANDROID_RESOURCE.equals(scheme)) { + dataSource = getRawResourceDataSource(); + } else if (SCHEME_NETEASE.equals(scheme)) { + dataSource = getNeteaseDataSource(); + } else { + dataSource = baseDataSource; + } + // Open the source and return. + return dataSource.open(dataSpec); + } + + @UnstableApi + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + return Assertions.checkNotNull(dataSource).read(buffer, offset, length); + } + + @UnstableApi + @Override + @Nullable + public Uri getUri() { + return dataSource == null ? null : dataSource.getUri(); + } + + @UnstableApi + @Override + public Map> getResponseHeaders() { + return dataSource == null ? Collections.emptyMap() : dataSource.getResponseHeaders(); + } + + @UnstableApi + @Override + public void close() throws IOException { + if (dataSource != null) { + try { + dataSource.close(); + } finally { + dataSource = null; + } + } + } + + private DataSource getUdpDataSource() { + if (udpDataSource == null) { + udpDataSource = new UdpDataSource(); + addListenersToDataSource(udpDataSource); + } + return udpDataSource; + } + + private DataSource getFileDataSource() { + if (fileDataSource == null) { + fileDataSource = new FileDataSource(); + addListenersToDataSource(fileDataSource); + } + return fileDataSource; + } + + private DataSource getAssetDataSource() { + if (assetDataSource == null) { + assetDataSource = new AssetDataSource(context); + addListenersToDataSource(assetDataSource); + } + return assetDataSource; + } + + private DataSource getContentDataSource() { + if (contentDataSource == null) { + contentDataSource = new ContentDataSource(context); + addListenersToDataSource(contentDataSource); + } + return contentDataSource; + } + + private DataSource getRtmpDataSource() { + if (rtmpDataSource == null) { + try { + Class clazz = Class.forName("androidx.media3.datasource.rtmp.RtmpDataSource"); + rtmpDataSource = (DataSource) clazz.getConstructor().newInstance(); + addListenersToDataSource(rtmpDataSource); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the RTMP extension. + Log.w(TAG, "Attempting to play RTMP stream without depending on the RTMP extension"); + } catch (Exception e) { + // The RTMP extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating RTMP extension", e); + } + if (rtmpDataSource == null) { + rtmpDataSource = baseDataSource; + } + } + return rtmpDataSource; + } + + private DataSource getDataSchemeDataSource() { + if (dataSchemeDataSource == null) { + dataSchemeDataSource = new DataSchemeDataSource(); + addListenersToDataSource(dataSchemeDataSource); + } + return dataSchemeDataSource; + } + + private DataSource getRawResourceDataSource() { + if (rawResourceDataSource == null) { + rawResourceDataSource = new RawResourceDataSource(context); + addListenersToDataSource(rawResourceDataSource); + } + return rawResourceDataSource; + } + + private DataSource getNeteaseDataSource() { + if (neteaseDataSource == null) { + neteaseDataSource = new OnlineMusicDataSource((Call.Factory) HttpClient.INSTANCE.getOkHttpClient()); + addListenersToDataSource(neteaseDataSource); + } + return neteaseDataSource; + } + + private void addListenersToDataSource(DataSource dataSource) { + for (int i = 0; i < transferListeners.size(); i++) { + dataSource.addTransferListener(transferListeners.get(i)); + } + } + + private void maybeAddListenerToDataSource( + @Nullable DataSource dataSource, TransferListener listener) { + if (dataSource != null) { + dataSource.addTransferListener(listener); + } + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/datasource/OnlineMusicDataSource.java b/AAmusic/app/src/main/java/me/wcy/music/net/datasource/OnlineMusicDataSource.java new file mode 100644 index 0000000..5ae1c9b --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/datasource/OnlineMusicDataSource.java @@ -0,0 +1,614 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package me.wcy.music.net.datasource; + +import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.datasource.HttpUtil.buildRangeRequestHeader; +import static java.lang.Math.min; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MediaLibraryInfo; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.util.Assertions; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.BaseDataSource; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DataSourceException; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.HttpUtil; +import androidx.media3.datasource.TransferListener; +import androidx.media3.datasource.okhttp.OkHttpDataSource; + +import com.google.common.base.Predicate; +import com.google.common.net.HttpHeaders; +import com.google.common.util.concurrent.SettableFuture; +import com.google.errorprone.annotations.CanIgnoreReturnValue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import okhttp3.CacheControl; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + *

+ * A copy of {@link OkHttpDataSource} which support get real url when play. + *

+ * An {@link HttpDataSource} that delegates to Square's {@link Call.Factory}. + * + *

Note: HTTP request headers will be set using all parameters passed via (in order of decreasing + * priority) the {@code dataSpec}, {@link #setRequestProperty} and the default parameters used to + * construct the instance. + */ +@UnstableApi +public class OnlineMusicDataSource extends BaseDataSource implements HttpDataSource { + + static { + MediaLibraryInfo.registerModule("media3.datasource.okhttp"); + } + + /** + * {@link DataSource.Factory} for {@link OnlineMusicDataSource} instances. + */ + public static final class Factory implements HttpDataSource.Factory { + + private final RequestProperties defaultRequestProperties; + private final Call.Factory callFactory; + + @Nullable + private String userAgent; + @Nullable + private TransferListener transferListener; + @Nullable + private CacheControl cacheControl; + @Nullable + private Predicate contentTypePredicate; + + /** + * Creates an instance. + * + * @param callFactory A {@link Call.Factory} (typically an {@link OkHttpClient}) for use by the + * sources created by the factory. + */ + public Factory(Call.Factory callFactory) { + this.callFactory = callFactory; + defaultRequestProperties = new RequestProperties(); + } + + @CanIgnoreReturnValue + @UnstableApi + @Override + public final Factory setDefaultRequestProperties(Map defaultRequestProperties) { + this.defaultRequestProperties.clearAndSet(defaultRequestProperties); + return this; + } + + /** + * Sets the user agent that will be used. + * + *

The default is {@code null}, which causes the default user agent of the underlying {@link + * OkHttpClient} to be used. + * + * @param userAgent The user agent that will be used, or {@code null} to use the default user + * agent of the underlying {@link OkHttpClient}. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + return this; + } + + /** + * Sets the {@link CacheControl} that will be used. + * + *

The default is {@code null}. + * + * @param cacheControl The cache control that will be used. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setCacheControl(@Nullable CacheControl cacheControl) { + this.cacheControl = cacheControl; + return this; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a + * {@link InvalidContentTypeException} is thrown from {@link + * OnlineMusicDataSource#open(DataSpec)}. + * + *

The default is {@code null}. + * + * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a + * predicate that was previously set. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setContentTypePredicate(@Nullable Predicate contentTypePredicate) { + this.contentTypePredicate = contentTypePredicate; + return this; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link DataSource#addTransferListener(TransferListener)}. + * + * @param transferListener The listener that will be used. + * @return This factory. + */ + @CanIgnoreReturnValue + @UnstableApi + public Factory setTransferListener(@Nullable TransferListener transferListener) { + this.transferListener = transferListener; + return this; + } + + @UnstableApi + @Override + public OnlineMusicDataSource createDataSource() { + OnlineMusicDataSource dataSource = + new OnlineMusicDataSource( + callFactory, userAgent, cacheControl, defaultRequestProperties, contentTypePredicate); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + private final Call.Factory callFactory; + private final RequestProperties requestProperties; + + @Nullable + private final String userAgent; + @Nullable + private final CacheControl cacheControl; + @Nullable + private final RequestProperties defaultRequestProperties; + + @Nullable + private Predicate contentTypePredicate; + @Nullable + private DataSpec dataSpec; + @Nullable + private Response response; + @Nullable + private InputStream responseByteStream; + private boolean opened; + private long bytesToRead; + private long bytesRead; + + /** + * @deprecated Use {@link OnlineMusicDataSource.Factory} instead. + */ + @SuppressWarnings("deprecation") + @UnstableApi + @Deprecated + public OnlineMusicDataSource(Call.Factory callFactory) { + this(callFactory, /* userAgent= */ null); + } + + /** + * @deprecated Use {@link OnlineMusicDataSource.Factory} instead. + */ + @SuppressWarnings("deprecation") + @UnstableApi + @Deprecated + public OnlineMusicDataSource(Call.Factory callFactory, @Nullable String userAgent) { + this(callFactory, userAgent, /* cacheControl= */ null, /* defaultRequestProperties= */ null); + } + + /** + * @deprecated Use {@link OnlineMusicDataSource.Factory} instead. + */ + @UnstableApi + @Deprecated + public OnlineMusicDataSource( + Call.Factory callFactory, + @Nullable String userAgent, + @Nullable CacheControl cacheControl, + @Nullable RequestProperties defaultRequestProperties) { + this( + callFactory, + userAgent, + cacheControl, + defaultRequestProperties, + /* contentTypePredicate= */ null); + } + + private OnlineMusicDataSource( + Call.Factory callFactory, + @Nullable String userAgent, + @Nullable CacheControl cacheControl, + @Nullable RequestProperties defaultRequestProperties, + @Nullable Predicate contentTypePredicate) { + super(/* isNetwork= */ true); + this.callFactory = Assertions.checkNotNull(callFactory); + this.userAgent = userAgent; + this.cacheControl = cacheControl; + this.defaultRequestProperties = defaultRequestProperties; + this.contentTypePredicate = contentTypePredicate; + this.requestProperties = new RequestProperties(); + } + + /** + * @deprecated Use {@link OnlineMusicDataSource.Factory#setContentTypePredicate(Predicate)} instead. + */ + @UnstableApi + @Deprecated + public void setContentTypePredicate(@Nullable Predicate contentTypePredicate) { + this.contentTypePredicate = contentTypePredicate; + } + + @UnstableApi + @Override + @Nullable + public Uri getUri() { + return response == null ? null : Uri.parse(response.request().url().toString()); + } + + @UnstableApi + @Override + public int getResponseCode() { + return response == null ? -1 : response.code(); + } + + @UnstableApi + @Override + public Map> getResponseHeaders() { + return response == null ? Collections.emptyMap() : response.headers().toMultimap(); + } + + @UnstableApi + @Override + public void setRequestProperty(String name, String value) { + Assertions.checkNotNull(name); + Assertions.checkNotNull(value); + requestProperties.set(name, value); + } + + @UnstableApi + @Override + public void clearRequestProperty(String name) { + Assertions.checkNotNull(name); + requestProperties.remove(name); + } + + @UnstableApi + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + @UnstableApi + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + this.dataSpec = dataSpec; + bytesRead = 0; + bytesToRead = 0; + transferInitializing(dataSpec); + + Request request = makeRequest(dataSpec); + Response response; + ResponseBody responseBody; + Call call = callFactory.newCall(request); + try { + this.response = executeCall(call); + response = this.response; + responseBody = Assertions.checkNotNull(response.body()); + responseByteStream = responseBody.byteStream(); + } catch (IOException e) { + throw HttpDataSourceException.createForIOException( + e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } + + int responseCode = response.code(); + + // Check for a valid response code. + if (!response.isSuccessful()) { + if (responseCode == 416) { + long documentSize = + HttpUtil.getDocumentSize(response.headers().get(HttpHeaders.CONTENT_RANGE)); + if (dataSpec.position == documentSize) { + opened = true; + transferStarted(dataSpec); + return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0; + } + } + + byte[] errorResponseBody; + try { + errorResponseBody = Util.toByteArray(Assertions.checkNotNull(responseByteStream)); + } catch (IOException e) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY; + } + Map> headers = response.headers().toMultimap(); + closeConnectionQuietly(); + @Nullable + IOException cause = + responseCode == 416 + ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + throw new InvalidResponseCodeException( + responseCode, response.message(), cause, headers, dataSpec, errorResponseBody); + } + + // Check for a valid content type. + @Nullable MediaType mediaType = responseBody.contentType(); + String contentType = mediaType != null ? mediaType.toString() : ""; + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpec); + } + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + long bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; + + // Determine the length of the data to be read, after skipping. + if (dataSpec.length != C.LENGTH_UNSET) { + bytesToRead = dataSpec.length; + } else { + long contentLength = responseBody.contentLength(); + bytesToRead = contentLength != -1 ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; + } + + opened = true; + transferStarted(dataSpec); + + try { + skipFully(bytesToSkip, dataSpec); + } catch (HttpDataSourceException e) { + closeConnectionQuietly(); + throw e; + } + + return bytesToRead; + } + + @UnstableApi + @Override + public int read(byte[] buffer, int offset, int length) throws HttpDataSourceException { + try { + return readInternal(buffer, offset, length); + } catch (IOException e) { + throw HttpDataSourceException.createForIOException( + e, castNonNull(dataSpec), HttpDataSourceException.TYPE_READ); + } + } + + @UnstableApi + @Override + public void close() { + if (opened) { + opened = false; + transferEnded(); + closeConnectionQuietly(); + } + } + + /** + * Establishes a connection. + */ + private Request makeRequest(DataSpec dataSpec) throws HttpDataSourceException { + String playUrl = OnlineMusicUriFetcher.INSTANCE.fetchPlayUrl(dataSpec.uri); + if (TextUtils.isEmpty(playUrl)) { + throw new HttpDataSourceException( + "Request song url error", + dataSpec, + PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK, + HttpDataSourceException.TYPE_OPEN + ); + } + + long position = dataSpec.position; + long length = dataSpec.length; + + @Nullable HttpUrl url = HttpUrl.parse(playUrl); + if (url == null) { + throw new HttpDataSourceException( + "Malformed URL", + dataSpec, + PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK, + HttpDataSourceException.TYPE_OPEN); + } + + Request.Builder builder = new Request.Builder().url(url); + if (cacheControl != null) { + builder.cacheControl(cacheControl); + } + + Map headers = new HashMap<>(); + if (defaultRequestProperties != null) { + headers.putAll(defaultRequestProperties.getSnapshot()); + } + + headers.putAll(requestProperties.getSnapshot()); + headers.putAll(dataSpec.httpRequestHeaders); + + for (Map.Entry header : headers.entrySet()) { + builder.header(header.getKey(), header.getValue()); + } + + @Nullable String rangeHeader = buildRangeRequestHeader(position, length); + if (rangeHeader != null) { + builder.addHeader(HttpHeaders.RANGE, rangeHeader); + } + if (userAgent != null) { + builder.addHeader(HttpHeaders.USER_AGENT, userAgent); + } + if (!dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)) { + builder.addHeader(HttpHeaders.ACCEPT_ENCODING, "identity"); + } + + @Nullable RequestBody requestBody = null; + if (dataSpec.httpBody != null) { + requestBody = RequestBody.create(dataSpec.httpBody); + } else if (dataSpec.httpMethod == DataSpec.HTTP_METHOD_POST) { + // OkHttp requires a non-null body for POST requests. + requestBody = RequestBody.create(Util.EMPTY_BYTE_ARRAY); + } + builder.method(dataSpec.getHttpMethodString(), requestBody); + return builder.build(); + } + + /** + * This method is an interrupt safe replacement of OkHttp Call.execute() which can get in bad + * states if interrupted while writing to the shared connection socket. + */ + private Response executeCall(Call call) throws IOException { + SettableFuture future = SettableFuture.create(); + call.enqueue( + new Callback() { + @Override + public void onFailure(Call call, IOException e) { + future.setException(e); + } + + @Override + public void onResponse(Call call, Response response) { + future.set(response); + } + }); + + try { + return future.get(); + } catch (InterruptedException e) { + call.cancel(); + throw new InterruptedIOException(); + } catch (ExecutionException ee) { + throw new IOException(ee); + } + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpec The {@link DataSpec}. + * @throws HttpDataSourceException If the thread is interrupted during the operation, or an error + * occurs while reading from the source, or if the data ended before skipping the specified + * number of bytes. + */ + private void skipFully(long bytesToSkip, DataSpec dataSpec) throws HttpDataSourceException { + if (bytesToSkip == 0) { + return; + } + byte[] skipBuffer = new byte[4096]; + try { + while (bytesToSkip > 0) { + int readLength = (int) min(bytesToSkip, skipBuffer.length); + int read = castNonNull(responseByteStream).read(skipBuffer, 0, readLength); + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException(); + } + if (read == -1) { + throw new HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN); + } + bytesToSkip -= read; + bytesTransferred(read); + } + return; + } catch (IOException e) { + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } else { + throw new HttpDataSourceException( + dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + } + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at index + * {@code offset}. + * + *

This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + private int readInternal(byte[] buffer, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesToRead != C.LENGTH_UNSET) { + long bytesRemaining = bytesToRead - bytesRead; + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = (int) min(readLength, bytesRemaining); + } + + int read = castNonNull(responseByteStream).read(buffer, offset, readLength); + if (read == -1) { + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + bytesTransferred(read); + return read; + } + + /** + * Closes the current connection quietly, if there is one. + */ + private void closeConnectionQuietly() { + if (response != null) { + Assertions.checkNotNull(response.body()).close(); + response = null; + } + responseByteStream = null; + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/net/datasource/OnlineMusicUriFetcher.kt b/AAmusic/app/src/main/java/me/wcy/music/net/datasource/OnlineMusicUriFetcher.kt new file mode 100644 index 0000000..1df07bf --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/net/datasource/OnlineMusicUriFetcher.kt @@ -0,0 +1,29 @@ +package me.wcy.music.net.datasource + +import android.net.Uri +import kotlinx.coroutines.runBlocking +import me.wcy.music.discover.DiscoverApi +import me.wcy.music.storage.preference.ConfigPreferences +import top.wangchenyan.common.net.apiCall + +/** + * + */ +object OnlineMusicUriFetcher { + + fun fetchPlayUrl(uri: Uri): String { + val songId = uri.getQueryParameter("id")?.toLongOrNull() ?: return uri.toString() + return runBlocking { + val res = apiCall { + DiscoverApi.get() + .getSongUrl(songId, ConfigPreferences.playSoundQuality) + } + + if (res.isSuccessWithData() && res.getDataOrThrow().isNotEmpty()) { + return@runBlocking res.getDataOrThrow().first().url + } else { + return@runBlocking "" + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/SearchApi.kt b/AAmusic/app/src/main/java/me/wcy/music/search/SearchApi.kt new file mode 100644 index 0000000..f454fb6 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/SearchApi.kt @@ -0,0 +1,53 @@ +package me.wcy.music.search + +import top.wangchenyan.common.net.NetResult +import top.wangchenyan.common.net.gson.GsonConverterFactory +import top.wangchenyan.common.utils.GsonUtils +import me.wcy.music.net.HttpClient +import me.wcy.music.search.bean.SearchResultData +import me.wcy.music.storage.preference.ConfigPreferences +import retrofit2.Retrofit +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * + */ +interface SearchApi { + + /** + * 搜索歌曲 + * @param type 搜索类型;默认为 1 即单曲 , 取值意义 : + * - 1: 单曲, + * - 10: 专辑, + * - 100: 歌手, + * - 1000: 歌单, + * - 1002: 用户, + * - 1004: MV, + * - 1006: 歌词, + * - 1009: 电台, + * - 1014: 视频, + * - 1018:综合, + * - 2000:声音(搜索声音返回字段格式会不一样) + */ + @POST("cloudsearch") + suspend fun search( + @Query("type") type: Int, + @Query("keywords") keywords: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): NetResult + + companion object { + private val api: SearchApi by lazy { + val retrofit = Retrofit.Builder() + .baseUrl(ConfigPreferences.apiDomain) + .addConverterFactory(GsonConverterFactory.create(GsonUtils.gson, true)) + .client(HttpClient.okHttpClient) + .build() + retrofit.create(SearchApi::class.java) + } + + fun get(): SearchApi = api + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/SearchFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/search/SearchFragment.kt new file mode 100644 index 0000000..a520027 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/SearchFragment.kt @@ -0,0 +1,127 @@ +package me.wcy.music.search + +import android.view.LayoutInflater +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.core.view.isVisible +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.blankj.utilcode.util.KeyboardUtils +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.viewBindings +import top.wangchenyan.common.widget.pager.TabLayoutPager +import me.wcy.music.R +import me.wcy.music.common.BaseMusicFragment +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.FragmentSearchBinding +import me.wcy.music.databinding.ItemSearchHistoryBinding +import me.wcy.music.databinding.TitleSearchBinding +import me.wcy.music.search.playlist.SearchPlaylistFragment +import me.wcy.music.search.song.SearchSongFragment +import me.wcy.router.annotation.Route + +/** + * + */ +@Route(RoutePath.SEARCH) +@AndroidEntryPoint +class SearchFragment : BaseMusicFragment() { + private val viewBinding by viewBindings() + private val titleBinding by lazy { + TitleSearchBinding.bind(getTitleLayout()!!.getContentView()!!) + } + private val viewModel by activityViewModels() + private val menuSearch by lazy { + getTitleLayout()!!.addTextMenu("搜索", false)!! + } + + override fun getRootView(): View { + return viewBinding.root + } + + override fun onLazyCreate() { + super.onLazyCreate() + + initTitle() + initTab() + initHistory() + + lifecycleScope.launch { + viewModel.showResult.collectLatest { showResult -> + viewBinding.llHistory.isVisible = showResult.not() + viewBinding.llResult.isVisible = showResult + } + } + + lifecycleScope.launch { + delay(200) + KeyboardUtils.showSoftInput(titleBinding.etSearch) + } + } + + private fun initTitle() { + titleBinding.etSearch.setOnEditorActionListener { v, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + menuSearch.performClick() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + menuSearch.setOnClickListener { + val keywords = titleBinding.etSearch.text?.trim()?.toString() ?: "" + if (keywords.isNotEmpty()) { + KeyboardUtils.hideSoftInput(requireActivity()) + viewModel.search(keywords) + } + } + } + + private fun initTab() { + val pager = TabLayoutPager( + lifecycle, + childFragmentManager, + viewBinding.viewPage2, + viewBinding.tabLayout + ) + pager.addFragment(SearchSongFragment(), "单曲") + pager.addFragment(SearchPlaylistFragment(), "歌单") + pager.setup() + } + + private fun initHistory() { + lifecycleScope.launch { + viewModel.historyKeywords.collectLatest { list -> + viewBinding.flHistory.removeAllViews() + list.forEach { text -> + ItemSearchHistoryBinding.inflate( + LayoutInflater.from(context), + viewBinding.flHistory, + true + ).apply { + root.text = text + root.setOnClickListener { + titleBinding.etSearch.setText(text) + titleBinding.etSearch.setSelection(text.length) + menuSearch.performClick() + } + } + } + } + } + } + + override fun onInterceptBackEvent(): Boolean { + if (viewModel.showResult.value) { + viewModel.showHistory() + return true + } + return super.onInterceptBackEvent() + } + + override fun getNavigationBarColor(): Int { + return R.color.play_bar_bg + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/SearchPreference.kt b/AAmusic/app/src/main/java/me/wcy/music/search/SearchPreference.kt new file mode 100644 index 0000000..ad0d77d --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/SearchPreference.kt @@ -0,0 +1,14 @@ +package me.wcy.music.search + +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.storage.IPreferencesFile +import top.wangchenyan.common.storage.PreferencesFile +import me.wcy.music.consts.PreferenceName + +/** + * + */ +object SearchPreference : + IPreferencesFile by PreferencesFile(CommonApp.app, PreferenceName.SEARCH) { + var historyKeywords by IPreferencesFile.ListProperty("history_keywords", String::class.java) +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/SearchViewModel.kt b/AAmusic/app/src/main/java/me/wcy/music/search/SearchViewModel.kt new file mode 100644 index 0000000..fc4da8e --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/SearchViewModel.kt @@ -0,0 +1,44 @@ +package me.wcy.music.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import top.wangchenyan.common.ext.toUnMutable +import me.wcy.music.consts.Consts + +/** + * + */ +class SearchViewModel : ViewModel() { + private val _keywords = MutableStateFlow("") + val keywords = _keywords.toUnMutable() + + private val _historyKeywords = MutableStateFlow(SearchPreference.historyKeywords ?: emptyList()) + val historyKeywords = _historyKeywords.toUnMutable() + + private val _showResult = MutableStateFlow(false) + val showResult = _showResult.toUnMutable() + + fun search(keywords: String) { + if (keywords.isEmpty()) { + return + } + _keywords.value = keywords + _showResult.value = true + + val list = _historyKeywords.value.toMutableList() + list.remove(keywords) + list.add(0, keywords) + val realList = list.take(Consts.SEARCH_HISTORY_COUNT) + _historyKeywords.value = realList + viewModelScope.launch(Dispatchers.IO) { + SearchPreference.historyKeywords = realList + } + } + + fun showHistory() { + _showResult.value = false + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/bean/SearchResultData.kt b/AAmusic/app/src/main/java/me/wcy/music/search/bean/SearchResultData.kt new file mode 100644 index 0000000..16c7061 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/bean/SearchResultData.kt @@ -0,0 +1,19 @@ +package me.wcy.music.search.bean + +import com.google.gson.annotations.SerializedName +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.common.bean.SongData + +/** + * + */ +data class SearchResultData( + @SerializedName("songs") + val songs: List = emptyList(), + @SerializedName("songCount") + val songCount: Int = 0, + @SerializedName("playlists") + val playlists: List = emptyList(), + @SerializedName("playlistCount") + val playlistCount: Int = 0, +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/playlist/SearchPlaylistFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/search/playlist/SearchPlaylistFragment.kt new file mode 100644 index 0000000..f998f84 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/playlist/SearchPlaylistFragment.kt @@ -0,0 +1,76 @@ +package me.wcy.music.search.playlist + +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import top.wangchenyan.common.model.CommonResult +import top.wangchenyan.common.net.apiCall +import me.wcy.music.common.SimpleMusicRefreshFragment +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.consts.Consts +import me.wcy.music.consts.RoutePath +import me.wcy.music.search.SearchApi +import me.wcy.music.search.SearchViewModel +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter + +/** + * + */ +@AndroidEntryPoint +class SearchPlaylistFragment : SimpleMusicRefreshFragment() { + private val viewModel by activityViewModels() + private val itemBinder by lazy { + SearchPlaylistItemBinder { item -> + CRouter.with(requireActivity()) + .url(RoutePath.PLAYLIST_DETAIL) + .extra("id", item.id) + .start() + }.apply { + keywords = viewModel.keywords.value + } + } + + override fun isShowTitle(): Boolean { + return false + } + + override fun isRefreshEnabled(): Boolean { + return false + } + + override fun onLazyCreate() { + super.onLazyCreate() + lifecycleScope.launch { + viewModel.keywords.collectLatest { + if (it.isNotEmpty()) { + showLoadSirLoading() + itemBinder.keywords = it + autoRefresh(true) + } + } + } + } + + override fun initAdapter(adapter: RAdapter) { + adapter.register(itemBinder) + } + + override suspend fun getData(page: Int): CommonResult> { + val keywords = viewModel.keywords.value + if (keywords.isEmpty()) { + return CommonResult.success(emptyList()) + } + val res = apiCall { + SearchApi.get() + .search(1000, keywords, Consts.PAGE_COUNT, (page - 1) * Consts.PAGE_COUNT) + } + return if (res.isSuccessWithData()) { + CommonResult.success(res.getDataOrThrow().playlists) + } else { + CommonResult.fail(res.code, res.msg) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/playlist/SearchPlaylistItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/search/playlist/SearchPlaylistItemBinder.kt new file mode 100644 index 0000000..2ffd9a2 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/playlist/SearchPlaylistItemBinder.kt @@ -0,0 +1,31 @@ +package me.wcy.music.search.playlist + +import android.annotation.SuppressLint +import com.blankj.utilcode.util.SizeUtils +import top.wangchenyan.common.ext.context +import me.wcy.music.common.bean.PlaylistData +import me.wcy.music.databinding.ItemSearchPlaylistBinding +import me.wcy.music.utils.ConvertUtils +import me.wcy.music.utils.ImageUtils.loadCover +import me.wcy.music.utils.MusicUtils +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class SearchPlaylistItemBinder(private val onItemClick: (PlaylistData) -> Unit) : + RItemBinder() { + var keywords = "" + + @SuppressLint("SetTextI18n") + override fun onBind(viewBinding: ItemSearchPlaylistBinding, item: PlaylistData, position: Int) { + viewBinding.root.setOnClickListener { + onItemClick(item) + } + viewBinding.ivCover.loadCover(item.getSmallCover(), SizeUtils.dp2px(4f)) + viewBinding.tvTitle.text = MusicUtils.keywordsTint(viewBinding.context, item.name, keywords) + viewBinding.tvSubTitle.text = "${item.trackCount}首 , by ${item.creator.nickname} , 播放${ + ConvertUtils.formatPlayCount(item.playCount, 1) + }次" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/song/SearchSongFragment.kt b/AAmusic/app/src/main/java/me/wcy/music/search/song/SearchSongFragment.kt new file mode 100644 index 0000000..4ea166f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/song/SearchSongFragment.kt @@ -0,0 +1,100 @@ +package me.wcy.music.search.song + +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.SimpleMusicRefreshFragment +import me.wcy.music.common.bean.SongData +import me.wcy.music.common.dialog.songmenu.SongMoreMenuDialog +import me.wcy.music.common.dialog.songmenu.items.AlbumMenuItem +import me.wcy.music.common.dialog.songmenu.items.ArtistMenuItem +import me.wcy.music.common.dialog.songmenu.items.CollectMenuItem +import me.wcy.music.common.dialog.songmenu.items.CommentMenuItem +import me.wcy.music.consts.Consts +import me.wcy.music.consts.RoutePath +import me.wcy.music.search.SearchApi +import me.wcy.music.search.SearchViewModel +import me.wcy.music.service.PlayerController +import me.wcy.music.utils.toMediaItem +import me.wcy.radapter3.RAdapter +import me.wcy.router.CRouter +import top.wangchenyan.common.model.CommonResult +import top.wangchenyan.common.net.apiCall +import javax.inject.Inject + +/** + + */ +@AndroidEntryPoint +class SearchSongFragment : SimpleMusicRefreshFragment() { + private val viewModel by activityViewModels() + private val itemBinder by lazy { + SearchSongItemBinder(object : OnItemClickListener2 { + override fun onItemClick(item: SongData, position: Int) { + playerController.addAndPlay(item.toMediaItem()) + CRouter.with(context).url(RoutePath.PLAYING).start() + } + + override fun onMoreClick(item: SongData, position: Int) { + SongMoreMenuDialog(requireActivity(), item) + .setItems( + listOf( + CollectMenuItem(lifecycleScope, item), + CommentMenuItem(item), + ArtistMenuItem(item), + AlbumMenuItem(item) + ) + ) + .show() + } + }).apply { + keywords = viewModel.keywords.value + } + } + + @Inject + lateinit var playerController: PlayerController + + override fun isShowTitle(): Boolean { + return false + } + + override fun isRefreshEnabled(): Boolean { + return false + } + + override fun onLazyCreate() { + super.onLazyCreate() + lifecycleScope.launch { + viewModel.keywords.collectLatest { + if (it.isNotEmpty()) { + showLoadSirLoading() + itemBinder.keywords = it + autoRefresh(true) + } + } + } + } + + override fun initAdapter(adapter: RAdapter) { + adapter.register(itemBinder) + } + + override suspend fun getData(page: Int): CommonResult> { + val keywords = viewModel.keywords.value + if (keywords.isEmpty()) { + return CommonResult.success(emptyList()) + } + val res = apiCall { + SearchApi.get().search(1, keywords, Consts.PAGE_COUNT, (page - 1) * Consts.PAGE_COUNT) + } + return if (res.isSuccessWithData()) { + CommonResult.success(res.getDataOrThrow().songs) + } else { + CommonResult.fail(res.code, res.msg) + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/search/song/SearchSongItemBinder.kt b/AAmusic/app/src/main/java/me/wcy/music/search/song/SearchSongItemBinder.kt new file mode 100644 index 0000000..eaf0e74 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/search/song/SearchSongItemBinder.kt @@ -0,0 +1,39 @@ +package me.wcy.music.search.song + +import androidx.core.view.isVisible +import top.wangchenyan.common.ext.context +import me.wcy.music.common.OnItemClickListener2 +import me.wcy.music.common.bean.SongData +import me.wcy.music.databinding.ItemSearchSongBinding +import me.wcy.music.utils.MusicUtils +import me.wcy.music.utils.getSimpleArtist +import me.wcy.radapter3.RItemBinder + +/** + * + */ +class SearchSongItemBinder(private val listener: OnItemClickListener2) : + RItemBinder() { + var keywords = "" + + override fun onBind(viewBinding: ItemSearchSongBinding, item: SongData, position: Int) { + viewBinding.root.setOnClickListener { + listener.onItemClick(item, position) + } + viewBinding.ivMore.setOnClickListener { + listener.onMoreClick(item, position) + } + viewBinding.tvTitle.text = MusicUtils.keywordsTint(viewBinding.context, item.name, keywords) + viewBinding.tvTag.isVisible = item.recommendReason.isNotEmpty() + viewBinding.tvTag.text = item.recommendReason + viewBinding.tvSubTitle.text = buildString { + append(item.getSimpleArtist()) + append(" - ") + append(item.al.name) + item.originSongSimpleData?.let { originSong -> + append(" | 原唱: ") + append(originSong.artists.joinToString("/") { it.name }) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/MusicService.kt b/AAmusic/app/src/main/java/me/wcy/music/service/MusicService.kt new file mode 100644 index 0000000..5308712 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/MusicService.kt @@ -0,0 +1,84 @@ +package me.wcy.music.service + +import android.app.PendingIntent +import android.content.Intent +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import com.blankj.utilcode.util.IntentUtils +import me.wcy.music.R +import me.wcy.music.net.datasource.MusicDataSource +import top.wangchenyan.common.CommonApp + +/** + * + */ +class MusicService : MediaSessionService() { + private lateinit var player: Player + private lateinit var session: MediaSession + + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + + @OptIn(UnstableApi::class) + player = ExoPlayer.Builder(applicationContext) + // 自动处理音频焦点 + .setAudioAttributes(AudioAttributes.DEFAULT, true) + // 自动暂停播放 + .setHandleAudioBecomingNoisy(true) + .setMediaSourceFactory( + DefaultMediaSourceFactory(applicationContext) + .setDataSourceFactory(MusicDataSource.Factory(applicationContext)) + ) + .build() + + session = MediaSession.Builder(this, player) + .setSessionActivity( + PendingIntent.getActivity( + this, + 0, + IntentUtils.getLaunchAppIntent(packageName).apply { + putExtra(EXTRA_NOTIFICATION, true) + action = Intent.ACTION_VIEW + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + .build() + + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(applicationContext).build().apply { + setSmallIcon(R.drawable.ic_notification) + } + ) + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + return session + } + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + player.stop() + } + + override fun onDestroy() { + super.onDestroy() + player.release() + session.release() + } + + companion object { + val EXTRA_NOTIFICATION = "${CommonApp.app.packageName}.notification" + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/PlayMode.kt b/AAmusic/app/src/main/java/me/wcy/music/service/PlayMode.kt new file mode 100644 index 0000000..5d21e02 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/PlayMode.kt @@ -0,0 +1,25 @@ +package me.wcy.music.service + +import androidx.annotation.StringRes +import me.wcy.music.R + +/** + * 播放模式 + * Created by wcy on 2015/12/26. + */ +sealed class PlayMode(val value: Int, @StringRes val nameRes: Int) { + object Loop : PlayMode(0, R.string.play_mode_loop) + object Shuffle : PlayMode(1, R.string.play_mode_shuffle) + object Single : PlayMode(2, R.string.play_mode_single) + + companion object { + fun valueOf(value: Int): PlayMode { + return when (value) { + 0 -> Loop + 1 -> Shuffle + 2 -> Single + else -> Loop + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/PlayServiceModule.kt b/AAmusic/app/src/main/java/me/wcy/music/service/PlayServiceModule.kt new file mode 100644 index 0000000..5bb93d3 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/PlayServiceModule.kt @@ -0,0 +1,51 @@ +package me.wcy.music.service + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import androidx.media3.common.Player +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.wcy.music.ext.accessEntryPoint +import me.wcy.music.storage.db.MusicDatabase +import top.wangchenyan.common.ext.toUnMutable + +/** + * + */ +@Module +@InstallIn(SingletonComponent::class) +object PlayServiceModule { + private var player: Player? = null + private var playerController: PlayerController? = null + + private val _isPlayerReady = MutableLiveData(false) + val isPlayerReady = _isPlayerReady.toUnMutable() + + fun setPlayer(player: Player) { + this.player = player + _isPlayerReady.value = true + } + + @Provides + fun providerPlayerController(db: MusicDatabase): PlayerController { + return playerController ?: run { + val player = player ?: throw IllegalStateException("Player not prepared!") + PlayerControllerImpl(player, db).also { + playerController = it + } + } + } + + fun Application.playerController(): PlayerController { + return accessEntryPoint().playerController() + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface PlayerControllerEntryPoint { + fun playerController(): PlayerController + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/PlayState.kt b/AAmusic/app/src/main/java/me/wcy/music/service/PlayState.kt new file mode 100644 index 0000000..9c36e5a --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/PlayState.kt @@ -0,0 +1,20 @@ +package me.wcy.music.service + +/** + * + */ +sealed class PlayState { + object Idle : PlayState() + object Preparing : PlayState() + object Playing : PlayState() + object Pause : PlayState() + + val isIdle: Boolean + get() = this is Idle + val isPreparing: Boolean + get() = this is Preparing + val isPlaying: Boolean + get() = this is Playing + val isPausing: Boolean + get() = this is Pause +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/PlayerController.kt b/AAmusic/app/src/main/java/me/wcy/music/service/PlayerController.kt new file mode 100644 index 0000000..58bb4c0 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/PlayerController.kt @@ -0,0 +1,54 @@ +package me.wcy.music.service + +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import androidx.media3.common.MediaItem +import kotlinx.coroutines.flow.StateFlow + +/** + * + */ +interface PlayerController { + val playlist: LiveData> + val currentSong: LiveData + val playState: StateFlow + val playProgress: StateFlow + val bufferingPercent: StateFlow + val playMode: StateFlow + + @MainThread + fun addAndPlay(song: MediaItem) + + @MainThread + fun replaceAll(songList: List, song: MediaItem) + + @MainThread + fun play(mediaId: String) + + @MainThread + fun delete(song: MediaItem) + + @MainThread + fun clearPlaylist() + + @MainThread + fun playPause() + + @MainThread + fun next() + + @MainThread + fun prev() + + @MainThread + fun seekTo(msec: Int) + + @MainThread + fun getAudioSessionId(): Int + + @MainThread + fun setPlayMode(mode: PlayMode) + + @MainThread + fun stop() +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/PlayerControllerImpl.kt b/AAmusic/app/src/main/java/me/wcy/music/service/PlayerControllerImpl.kt new file mode 100644 index 0000000..0068d5b --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/PlayerControllerImpl.kt @@ -0,0 +1,315 @@ +package me.wcy.music.service + +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.lifecycle.MutableLiveData +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.wcy.music.storage.db.MusicDatabase +import me.wcy.music.storage.preference.ConfigPreferences +import me.wcy.music.utils.toMediaItem +import me.wcy.music.utils.toSongEntity +import top.wangchenyan.common.ext.toUnMutable +import top.wangchenyan.common.ext.toast + +/** + * + */ +class PlayerControllerImpl( + private val player: Player, + private val db: MusicDatabase, +) : PlayerController, CoroutineScope by MainScope() { + + private val _playlist = MutableLiveData(emptyList()) + override val playlist = _playlist.toUnMutable() + + private val _currentSong = MutableLiveData(null) + override val currentSong = _currentSong.toUnMutable() + + private val _playState = MutableStateFlow(PlayState.Idle) + override val playState = _playState.toUnMutable() + + private val _playProgress = MutableStateFlow(0) + override val playProgress = _playProgress.toUnMutable() + + private val _bufferingPercent = MutableStateFlow(0) + override val bufferingPercent = _bufferingPercent.toUnMutable() + + private val _playMode = MutableStateFlow(PlayMode.valueOf(ConfigPreferences.playMode)) + override val playMode: StateFlow = _playMode + + private var audioSessionId = 0 + + init { + player.playWhenReady = false + player.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + when (playbackState) { + Player.STATE_IDLE -> { + _playState.value = PlayState.Idle + _playProgress.value = 0 + _bufferingPercent.value = 0 + } + + Player.STATE_BUFFERING -> { + _playState.value = PlayState.Preparing + } + + Player.STATE_READY -> { + player.play() + _playState.value = PlayState.Playing + } + + Player.STATE_ENDED -> { + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + if (player.playbackState == Player.STATE_READY) { + _playState.value = if (isPlaying) PlayState.Playing else PlayState.Pause + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + mediaItem ?: return + val playlist = _playlist.value ?: return + _currentSong.value = playlist.find { it.mediaId == mediaItem.mediaId } + } + + @OptIn(UnstableApi::class) + override fun onAudioSessionIdChanged(audioSessionId: Int) { + super.onAudioSessionIdChanged(audioSessionId) + this@PlayerControllerImpl.audioSessionId = audioSessionId + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + stop() + toast("播放失败(${error.errorCodeName},${error.localizedMessage})") + } + }) + setPlayMode(PlayMode.valueOf(ConfigPreferences.playMode)) + + launch(Dispatchers.Main.immediate) { + val playlist = withContext(Dispatchers.IO) { + db.playlistDao() + .queryAll() + .map { it.toMediaItem() } + } + if (playlist.isNotEmpty()) { + _playlist.value = playlist + player.setMediaItems(playlist) + val currentSongId = ConfigPreferences.currentSongId + if (currentSongId.isNotEmpty()) { + val currentSongIndex = playlist.indexOfFirst { + it.mediaId == currentSongId + }.coerceAtLeast(0) + _currentSong.value = playlist[currentSongIndex] + player.seekTo(currentSongIndex, 0) + } + } + + _currentSong.observeForever { + ConfigPreferences.currentSongId = it?.mediaId ?: "" + } + } + + launch { + while (true) { + if (player.isPlaying) { + _playProgress.value = player.currentPosition + } + delay(1000) + } + } + } + + @MainThread + override fun addAndPlay(song: MediaItem) { + launch(Dispatchers.Main.immediate) { + val newPlaylist = _playlist.value?.toMutableList() ?: mutableListOf() + val index = newPlaylist.indexOfFirst { it.mediaId == song.mediaId } + if (index >= 0) { + newPlaylist[index] = song + player.replaceMediaItem(index, song) + } else { + newPlaylist.add(song) + player.addMediaItem(song) + } + withContext(Dispatchers.IO) { + db.playlistDao().clear() + db.playlistDao().insertAll(newPlaylist.map { it.toSongEntity() }) + } + _playlist.value = newPlaylist + play(song.mediaId) + } + } + + @MainThread + override fun replaceAll(songList: List, song: MediaItem) { + launch(Dispatchers.Main.immediate) { + withContext(Dispatchers.IO) { + db.playlistDao().clear() + db.playlistDao().insertAll(songList.map { it.toSongEntity() }) + } + stop() + player.setMediaItems(songList) + _playlist.value = songList + _currentSong.value = song + play(song.mediaId) + } + } + + @MainThread + override fun play(mediaId: String) { + val playlist = _playlist.value + if (playlist.isNullOrEmpty()) { + return + } + val index = playlist.indexOfFirst { it.mediaId == mediaId } + if (index < 0) { + return + } + + stop() + player.seekTo(index, 0) + player.prepare() + + _currentSong.value = playlist[index] + _playProgress.value = 0 + _bufferingPercent.value = 0 + } + + @MainThread + override fun delete(song: MediaItem) { + launch(Dispatchers.Main.immediate) { + val playlist = _playlist.value?.toMutableList() ?: mutableListOf() + val index = playlist.indexOfFirst { it.mediaId == song.mediaId } + if (index < 0) return@launch + if (playlist.size == 1) { + clearPlaylist() + } else { + playlist.removeAt(index) + _playlist.value = playlist + withContext(Dispatchers.IO) { + db.playlistDao().delete(song.toSongEntity()) + } + player.removeMediaItem(index) + } + } + } + + @MainThread + override fun clearPlaylist() { + launch(Dispatchers.Main.immediate) { + withContext(Dispatchers.IO) { + db.playlistDao().clear() + } + stop() + player.clearMediaItems() + _playlist.value = emptyList() + _currentSong.value = null + } + } + + @MainThread + override fun playPause() { + if (player.mediaItemCount == 0) return + when (player.playbackState) { + Player.STATE_IDLE -> { + player.prepare() + } + + Player.STATE_BUFFERING -> { + stop() + } + + Player.STATE_READY -> { + if (player.isPlaying) { + player.pause() + _playState.value = PlayState.Pause + } else { + player.play() + _playState.value = PlayState.Playing + } + } + + Player.STATE_ENDED -> { + player.seekToNextMediaItem() + player.prepare() + } + } + } + + @MainThread + override fun next() { + if (player.mediaItemCount == 0) return + player.seekToNextMediaItem() + player.prepare() + _playProgress.value = 0 + _bufferingPercent.value = 0 + } + + @MainThread + override fun prev() { + if (player.mediaItemCount == 0) return + player.seekToPreviousMediaItem() + player.prepare() + _playProgress.value = 0 + _bufferingPercent.value = 0 + } + + @MainThread + override fun seekTo(msec: Int) { + if (player.playbackState == Player.STATE_READY) { + player.seekTo(msec.toLong()) + } + } + + @MainThread + override fun getAudioSessionId(): Int { + return audioSessionId + } + + @MainThread + override fun setPlayMode(mode: PlayMode) { + ConfigPreferences.playMode = mode.value + _playMode.value = mode + when (mode) { + PlayMode.Loop -> { + player.repeatMode = Player.REPEAT_MODE_ALL + player.shuffleModeEnabled = false + } + + PlayMode.Shuffle -> { + player.repeatMode = Player.REPEAT_MODE_ALL + player.shuffleModeEnabled = true + } + + PlayMode.Single -> { + player.repeatMode = Player.REPEAT_MODE_ONE + player.shuffleModeEnabled = false + } + } + } + + @MainThread + override fun stop() { + player.stop() + _playState.value = PlayState.Idle + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessor.kt b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessor.kt new file mode 100644 index 0000000..01d241f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessor.kt @@ -0,0 +1,19 @@ +package me.wcy.music.service.likesong + +import android.app.Activity +import top.wangchenyan.common.model.CommonResult + + +/** + * + */ +interface LikeSongProcessor { + + fun init() + + fun updateLikeSongList() + + fun isLiked(id: Long): Boolean + + suspend fun like(activity: Activity, id: Long): CommonResult +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessorImpl.kt b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessorImpl.kt new file mode 100644 index 0000000..0ea62f1 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessorImpl.kt @@ -0,0 +1,87 @@ +package me.wcy.music.service.likesong + +import android.app.Activity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.account.service.UserService +import me.wcy.music.mine.MineApi +import top.wangchenyan.common.model.CommonResult +import top.wangchenyan.common.net.apiCall +import javax.inject.Inject +import javax.inject.Singleton + +/** + * + */ +@Singleton +class LikeSongProcessorImpl @Inject constructor( + private val userService: UserService +) : LikeSongProcessor, CoroutineScope by MainScope() { + private val likeSongSet = mutableSetOf() + + override fun init() { + launch { + userService.profile.collectLatest { + if (it != null) { + updateLikeSongList() + } else { + likeSongSet.clear() + } + } + } + } + + override fun updateLikeSongList() { + if (userService.isLogin().not()) return + launch { + val res = runCatching { + MineApi.get().getMyLikeSongList(userService.getUserId()) + } + val data = res.getOrNull() + if (data?.code == 200) { + likeSongSet.clear() + likeSongSet.addAll(data.ids) + } + } + } + + override fun isLiked(id: Long): Boolean { + if (userService.isLogin().not()) { + return false + } + return likeSongSet.contains(id) + } + + override suspend fun like(activity: Activity, id: Long): CommonResult { + if (userService.isLogin().not()) { + userService.checkLogin(activity) + return CommonResult.fail() + } + val isLike = isLiked(id) + if (isLike) { + val res = apiCall { + MineApi.get().likeSong(id, false) + } + return if (res.isSuccess()) { + likeSongSet.remove(id) + updateLikeSongList() + CommonResult.success(Unit) + } else { + CommonResult.fail(res.code, res.msg) + } + } else { + val res = apiCall { + MineApi.get().likeSong(id, true) + } + return if (res.isSuccess()) { + likeSongSet.add(id) + updateLikeSongList() + CommonResult.success(Unit) + } else { + CommonResult.fail(res.code, res.msg) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessorModule.kt b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessorModule.kt new file mode 100644 index 0000000..9bc0c94 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/LikeSongProcessorModule.kt @@ -0,0 +1,34 @@ +package me.wcy.music.service.likesong + +import android.app.Application +import dagger.Binds +import dagger.Module +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import me.wcy.music.ext.accessEntryPoint + +/** + * + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class LikeSongProcessorModule { + + @Binds + abstract fun bindLikeSongProcessor( + likeSongProcessor: LikeSongProcessorImpl + ): LikeSongProcessor + + companion object { + fun Application.audioPlayer(): LikeSongProcessor { + return accessEntryPoint().likeSongProcessor() + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface LikeSongProcessorEntryPoint { + fun likeSongProcessor(): LikeSongProcessor + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/service/likesong/bean/LikeSongListData.kt b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/bean/LikeSongListData.kt new file mode 100644 index 0000000..5f3df4f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/service/likesong/bean/LikeSongListData.kt @@ -0,0 +1,13 @@ +package me.wcy.music.service.likesong.bean + +import com.google.gson.annotations.SerializedName + +/** + * + */ +data class LikeSongListData( + @SerializedName("code") + val code: Int = 0, + @SerializedName("ids") + val ids: Set = emptySet() +) diff --git a/AAmusic/app/src/main/java/me/wcy/music/storage/LrcCache.kt b/AAmusic/app/src/main/java/me/wcy/music/storage/LrcCache.kt new file mode 100644 index 0000000..d44c7f7 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/storage/LrcCache.kt @@ -0,0 +1,42 @@ +package me.wcy.music.storage + +import androidx.media3.common.MediaItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.wcy.music.consts.FilePath +import me.wcy.music.utils.getSongId +import me.wcy.music.utils.isLocal +import java.io.File + +/** + * + */ +object LrcCache { + + /** + * 获取歌词路径 + */ + fun getLrcFilePath(music: MediaItem): String? { + if (music.isLocal()) { + val audioFile = File(music.localConfiguration?.uri?.toString() ?: "") + val lrcFile = File(audioFile.parent, "${audioFile.nameWithoutExtension}.lrc") + if (lrcFile.exists()) { + return lrcFile.path + } + } else { + val lrcFile = File(FilePath.lrcDir, music.getSongId().toString()) + if (lrcFile.exists()) { + return lrcFile.path + } + } + return null + } + + suspend fun saveLrcFile(music: MediaItem, content: String): File { + return withContext(Dispatchers.IO) { + File(FilePath.lrcDir, music.getSongId().toString()).also { + it.writeText(content) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/storage/db/DatabaseModule.kt b/AAmusic/app/src/main/java/me/wcy/music/storage/db/DatabaseModule.kt new file mode 100644 index 0000000..882c421 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/storage/db/DatabaseModule.kt @@ -0,0 +1,25 @@ +package me.wcy.music.storage.db + +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import top.wangchenyan.common.CommonApp + +/** + * + */ +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + fun provideAppDatabase(): MusicDatabase { + return Room.databaseBuilder( + CommonApp.app, + MusicDatabase::class.java, + "music_db" + ).build() + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/storage/db/MusicDatabase.kt b/AAmusic/app/src/main/java/me/wcy/music/storage/db/MusicDatabase.kt new file mode 100644 index 0000000..5badb27 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/storage/db/MusicDatabase.kt @@ -0,0 +1,20 @@ +package me.wcy.music.storage.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import me.wcy.music.storage.db.dao.PlaylistDao +import me.wcy.music.storage.db.entity.SongEntity + +/** + * + */ +@Database( + entities = [ + SongEntity::class, + ], + version = 1 +) +abstract class MusicDatabase : RoomDatabase() { + + abstract fun playlistDao(): PlaylistDao +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/storage/db/dao/PlaylistDao.kt b/AAmusic/app/src/main/java/me/wcy/music/storage/db/dao/PlaylistDao.kt new file mode 100644 index 0000000..611fee1 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/storage/db/dao/PlaylistDao.kt @@ -0,0 +1,32 @@ +package me.wcy.music.storage.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import me.wcy.music.storage.db.entity.SongEntity + +/** + * + */ +@Dao +interface PlaylistDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(entity: SongEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(list: List) + + @Query("SELECT * FROM play_list") + fun queryAll(): List + + @Query("SELECT * FROM play_list WHERE unique_id = :uniqueId") + fun queryByUniqueId(uniqueId: String): SongEntity? + + @Delete + fun delete(entity: SongEntity) + + @Query("DELETE FROM play_list") + fun clear() +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/storage/db/entity/SongEntity.kt b/AAmusic/app/src/main/java/me/wcy/music/storage/db/entity/SongEntity.kt new file mode 100644 index 0000000..3d07b28 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/storage/db/entity/SongEntity.kt @@ -0,0 +1,97 @@ +package me.wcy.music.storage.db.entity + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize +import me.wcy.music.utils.MusicUtils.asLargeCover +import me.wcy.music.utils.MusicUtils.asSmallCover +import me.wcy.music.utils.generateUniqueId + +/** + * + */ +@Parcelize +@Entity("play_list", indices = [Index("title"), Index("artist"), Index("album")]) +data class SongEntity( + // 歌曲类型:本地/网络 + @ColumnInfo("type") + val type: Int = 0, + + // 歌曲ID + @ColumnInfo("song_id") + val songId: Long = 0, + + // 音乐标题 + @ColumnInfo("title") + val title: String = "", + + // 艺术家 + @ColumnInfo("artist") + val artist: String = "", + + // 艺术家ID + @ColumnInfo("artist_id") + val artistId: Long = 0, + + // 专辑 + @ColumnInfo("album") + val album: String = "", + + // 专辑ID + @ColumnInfo("album_id") + val albumId: Long = 0, + + // 专辑封面 + @Deprecated("Please use resized url") + @ColumnInfo("album_cover") + val albumCover: String = "", + + // 持续时间 + @ColumnInfo("duration") + val duration: Long = 0, + + // 播放地址 + @ColumnInfo("path") + var path: String = "", + + // [本地]文件名 + @ColumnInfo("file_name") + val fileName: String = "", + + // [本地]文件大小 + @ColumnInfo("file_size") + val fileSize: Long = 0, +) : Parcelable { + @PrimaryKey + @ColumnInfo("unique_id") + var uniqueId: String = generateUniqueId(type, songId) + + override fun hashCode(): Int { + return uniqueId.hashCode() + } + + override fun equals(other: Any?): Boolean { + return other is SongEntity + && other.uniqueId == this.uniqueId + } + + fun isLocal() = type == LOCAL + + fun getSmallCover(): String { + if (isLocal()) return albumCover + return albumCover.asSmallCover() + } + + fun getLargeCover(): String { + if (isLocal()) return albumCover + return albumCover.asLargeCover() + } + + companion object { + const val LOCAL = 0 + const val ONLINE = 1 + } +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/storage/preference/ConfigPreferences.kt b/AAmusic/app/src/main/java/me/wcy/music/storage/preference/ConfigPreferences.kt new file mode 100644 index 0000000..0aac11d --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/storage/preference/ConfigPreferences.kt @@ -0,0 +1,48 @@ +package me.wcy.music.storage.preference + +import com.blankj.utilcode.util.StringUtils +import me.wcy.music.R +import me.wcy.music.common.DarkModeService +import me.wcy.music.consts.PreferenceName +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.storage.IPreferencesFile +import top.wangchenyan.common.storage.PreferencesFile + +/** + * SharedPreferences工具类 + * Created by wcy on 2015/11/28. + */ +object ConfigPreferences : + IPreferencesFile by PreferencesFile(CommonApp.app, PreferenceName.CONFIG, false) { + + var playSoundQuality by IPreferencesFile.StringProperty( + StringUtils.getString(R.string.setting_key_play_sound_quality), + "standard" + ) + + var downloadSoundQuality by IPreferencesFile.StringProperty( + StringUtils.getString(R.string.setting_key_download_sound_quality), + "standard" + ) + + var filterSize by IPreferencesFile.StringProperty( + StringUtils.getString(R.string.setting_key_filter_size), + "0" + ) + + var filterTime by IPreferencesFile.StringProperty( + StringUtils.getString(R.string.setting_key_filter_time), + "0" + ) + + var darkMode by IPreferencesFile.StringProperty( + "dark_mode", + DarkModeService.DarkMode.Auto.value + ) + + var playMode: Int by IPreferencesFile.IntProperty("play_mode", 0) + + var currentSongId: String by IPreferencesFile.StringProperty("current_song_id", "") + + var apiDomain: String by IPreferencesFile.StringProperty("api_domain", "") +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/utils/ConvertUtils.kt b/AAmusic/app/src/main/java/me/wcy/music/utils/ConvertUtils.kt new file mode 100644 index 0000000..f947c7f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/utils/ConvertUtils.kt @@ -0,0 +1,23 @@ +package me.wcy.music.utils + +import top.wangchenyan.common.ext.divide +import top.wangchenyan.common.ext.format +import java.math.RoundingMode + +/** + * + */ +object ConvertUtils { + + fun formatPlayCount(num: Long, dot: Int = 0): String { + return if (num < 100000) { + num.toString() + } else if (num < 100000000) { + val wan = num.toDouble().divide(10000.0).format(dot, RoundingMode.HALF_DOWN) + wan + "万" + } else { + val wan = num.toDouble().divide(100000000.0).format(dot, RoundingMode.HALF_DOWN) + wan + "亿" + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/utils/ImageUtils.kt b/AAmusic/app/src/main/java/me/wcy/music/utils/ImageUtils.kt new file mode 100644 index 0000000..ff64f55 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/utils/ImageUtils.kt @@ -0,0 +1,38 @@ +package me.wcy.music.utils + +import android.graphics.Bitmap +import android.widget.ImageView +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import top.wangchenyan.common.ext.load +import me.wcy.music.R + +/** + * 图像工具类 + * Created by wcy on 2015/11/29. + */ +object ImageUtils { + + /** + * 将图片放大或缩小到指定尺寸 + */ + fun resizeImage(source: Bitmap, dstWidth: Int, dstHeight: Int): Bitmap { + return if (source.width == dstWidth && source.height == dstHeight) { + source + } else { + Bitmap.createScaledBitmap(source, dstWidth, dstHeight, true) + } + } + + fun ImageView.loadCover(url: Any?, corners: Int) { + load(url) { + placeholder(R.drawable.ic_default_cover) + error(R.drawable.ic_default_cover) + + if (corners > 0) { + // 圆角和 CenterCrop 不兼容,需同时设置 + transform(CenterCrop(), RoundedCorners(corners)) + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/utils/ModelEx.kt b/AAmusic/app/src/main/java/me/wcy/music/utils/ModelEx.kt new file mode 100644 index 0000000..ecf64e0 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/utils/ModelEx.kt @@ -0,0 +1,140 @@ +package me.wcy.music.utils + +import android.net.Uri +import androidx.core.os.bundleOf +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import me.wcy.music.common.bean.SongData +import me.wcy.music.storage.db.entity.SongEntity +import top.wangchenyan.common.CommonApp + +/** + * + */ + +const val SCHEME_NETEASE = "netease" +const val PARAM_ID = "id" +const val EXTRA_DURATION = "duration" +const val EXTRA_FILE_NAME = "file_name" +const val EXTRA_FILE_SIZE = "file_size" +const val EXTRA_SMALL_COVER = "small_cover" + +fun SongData.getSimpleArtist(): String { + return ar.joinToString("/") { it.name } +} + +fun SongEntity.toMediaItem(): MediaItem { + return MediaItem.Builder() + .setMediaId(uniqueId) + .setUri(path) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setAlbumArtist(artist) + .setArtworkUri(Uri.parse(getLargeCover())) + .setSmallCover(getSmallCover()) + .setDuration(duration) + .setFileName(fileName) + .setFileSize(fileSize) + .build() + ) + .build() +} + +fun MediaItem.toSongEntity(): SongEntity { + return SongEntity( + type = getSongType(), + songId = getSongId(), + title = mediaMetadata.title?.toString() ?: "", + artist = mediaMetadata.artist?.toString() ?: "", + artistId = 0, + album = mediaMetadata.albumTitle?.toString() ?: "", + albumId = 0, + albumCover = mediaMetadata.artworkUri?.toString() ?: "", + duration = mediaMetadata.getDuration(), + path = localConfiguration?.uri?.toString() ?: "", + fileName = mediaMetadata.getFileName(), + fileSize = mediaMetadata.getFileSize() + ) +} + +fun SongData.toMediaItem(): MediaItem { + val uri = Uri.Builder() + .scheme(SCHEME_NETEASE) + .authority(CommonApp.app.packageName) + .appendQueryParameter(PARAM_ID, id.toString()) + .build() + return MediaItem.Builder() + .setMediaId(generateUniqueId(SongEntity.ONLINE, id)) + .setUri(uri) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(name) + .setArtist(getSimpleArtist()) + .setAlbumTitle(al.name) + .setAlbumArtist(getSimpleArtist()) + .setArtworkUri(Uri.parse(al.getLargeCover())) + .setSmallCover(al.getSmallCover()) + .setDuration(dt) + .build() + ) + .build() +} + +fun generateUniqueId(type: Int, songId: Long): String { + return "$type#$songId" +} + +fun MediaItem.isLocal(): Boolean { + return getSongType() == SongEntity.LOCAL +} + +fun MediaItem.getSongType(): Int { + return mediaId.split("#").firstOrNull()?.toIntOrNull() ?: SongEntity.LOCAL +} + +fun MediaItem.getSongId(): Long { + return mediaId.split("#").getOrNull(1)?.toLongOrNull() ?: 0L +} + +fun MediaMetadata.Builder.setDuration(duration: Long) = apply { + val extras = build().extras ?: bundleOf() + extras.putLong(EXTRA_DURATION, duration) + setExtras(extras) +} + +fun MediaMetadata.getDuration(): Long { + return extras?.getLong(EXTRA_DURATION) ?: 0 +} + +fun MediaMetadata.Builder.setFileName(name: String) = apply { + val extras = build().extras ?: bundleOf() + extras.putString(EXTRA_FILE_NAME, name) + setExtras(extras) +} + +fun MediaMetadata.getFileName(): String { + return extras?.getString(EXTRA_FILE_NAME) ?: "" +} + +fun MediaMetadata.Builder.setFileSize(size: Long) = apply { + val extras = build().extras ?: bundleOf() + extras.putLong(EXTRA_FILE_SIZE, size) + setExtras(extras) +} + +fun MediaMetadata.getFileSize(): Long { + return extras?.getLong(EXTRA_FILE_SIZE) ?: 0 +} + +fun MediaMetadata.Builder.setSmallCover(value: String) = apply { + val extras = build().extras ?: bundleOf() + extras.putString(EXTRA_SMALL_COVER, value) + setExtras(extras) +} + +fun MediaMetadata.getSmallCover(): String { + return extras?.getString(EXTRA_SMALL_COVER) ?: "" +} diff --git a/AAmusic/app/src/main/java/me/wcy/music/utils/MusicUtils.kt b/AAmusic/app/src/main/java/me/wcy/music/utils/MusicUtils.kt new file mode 100644 index 0000000..04e2ae9 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/utils/MusicUtils.kt @@ -0,0 +1,78 @@ +package me.wcy.music.utils + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.audiofx.AudioEffect +import android.text.TextUtils +import androidx.core.text.buildSpannedString +import top.wangchenyan.common.ext.getColorEx +import top.wangchenyan.common.widget.CustomSpan.appendStyle +import me.wcy.music.R + +/** + * 歌曲工具类 + * Created by wcy on 2015/11/27. + */ +object MusicUtils { + + fun isAudioControlPanelAvailable(context: Context): Boolean { + return isIntentAvailable( + context, + Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) + ) + } + + private fun isIntentAvailable(context: Context, intent: Intent): Boolean { + return context.packageManager.resolveActivity( + intent, + PackageManager.GET_RESOLVED_FILTER + ) != null + } + + fun getArtistAndAlbum(artist: String?, album: String?): String? { + return if (TextUtils.isEmpty(artist) && TextUtils.isEmpty(album)) { + "" + } else if (!TextUtils.isEmpty(artist) && TextUtils.isEmpty(album)) { + artist + } else if (TextUtils.isEmpty(artist) && !TextUtils.isEmpty(album)) { + album + } else { + "$artist - $album" + } + } + + fun keywordsTint(context: Context, text: String, keywords: String): CharSequence { + if (text.isEmpty() || keywords.isEmpty()) { + return text + } + val splitText = text.split(keywords) + return buildSpannedString { + splitText.forEachIndexed { index, s -> + append(s) + if (index < splitText.size - 1) { + appendStyle( + keywords, + color = context.getColorEx(R.color.common_theme_color) + ) + } + } + } + } + + fun String.asSmallCover(): String { + return appendImageSize(200) + } + + fun String.asLargeCover(): String { + return appendImageSize(800) + } + + private fun String.appendImageSize(size: Int): String { + return if (contains("?")) { + "$this¶m=${size}y${size}" + } else { + "$this?param=${size}y${size}" + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/utils/QuitTimer.kt b/AAmusic/app/src/main/java/me/wcy/music/utils/QuitTimer.kt new file mode 100644 index 0000000..357545f --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/utils/QuitTimer.kt @@ -0,0 +1,51 @@ +package me.wcy.music.utils + +import android.os.Handler +import android.os.Looper +import android.text.format.DateUtils + +/** + * Created by hzwangchenyan on 2017/8/8. + */ +class QuitTimer(private val listener: OnTimerListener) { + private val handler: Handler by lazy { + Handler(Looper.getMainLooper()) + } + private var timerRemain: Long = 0 + + fun start(milli: Long) { + stop() + if (milli > 0) { + timerRemain = milli + DateUtils.SECOND_IN_MILLIS + handler.post(mQuitRunnable) + } else { + timerRemain = 0 + listener.onTick(timerRemain) + } + } + + fun stop() { + handler.removeCallbacks(mQuitRunnable) + } + + private val mQuitRunnable: Runnable = object : Runnable { + override fun run() { + timerRemain -= DateUtils.SECOND_IN_MILLIS + if (timerRemain > 0) { + listener.onTick(timerRemain) + handler.postDelayed(this, DateUtils.SECOND_IN_MILLIS) + } else { + listener.onTimeEnd() + } + } + } + + interface OnTimerListener { + /** + * 更新定时停止播放时间 + */ + fun onTick(remain: Long) + + fun onTimeEnd() + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/utils/TimeUtils.kt b/AAmusic/app/src/main/java/me/wcy/music/utils/TimeUtils.kt new file mode 100644 index 0000000..bad84d6 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/utils/TimeUtils.kt @@ -0,0 +1,21 @@ +package me.wcy.music.utils + +import android.text.format.DateUtils +import java.util.Locale + +/** + * Created by hzwangchenyan on 2016/3/22. + */ +object TimeUtils { + fun formatMs(milli: Long): String { + return formatTime("mm:ss", milli) + } + + fun formatTime(pattern: String, milli: Long): String { + val m = (milli / DateUtils.MINUTE_IN_MILLIS).toInt() + val s = (milli / DateUtils.SECOND_IN_MILLIS % 60).toInt() + val mm = String.format(Locale.getDefault(), "%02d", m) + val ss = String.format(Locale.getDefault(), "%02d", s) + return pattern.replace("mm", mm).replace("ss", ss) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/widget/AlbumCoverView.kt b/AAmusic/app/src/main/java/me/wcy/music/widget/AlbumCoverView.kt new file mode 100644 index 0000000..ca3e934 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/widget/AlbumCoverView.kt @@ -0,0 +1,204 @@ +package me.wcy.music.widget + +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Point +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.view.animation.LinearInterpolator +import androidx.core.content.res.ResourcesCompat +import com.blankj.utilcode.util.SizeUtils +import me.wcy.music.R +import me.wcy.music.utils.ImageUtils +import top.wangchenyan.common.ext.startOrResume + +/** + * 专辑封面 + * Created by wcy on 2015/11/30. + */ +class AlbumCoverView @JvmOverloads constructor( + context: Context?, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + private val coverBorder: Drawable by lazy { + ResourcesCompat.getDrawable(resources, R.drawable.bg_playing_cover_border, null)!! + } + + private var discBitmap = BitmapFactory.decodeResource(resources, R.drawable.bg_playing_disc) + private val discMatrix by lazy { Matrix() } + private val discStartPoint by lazy { Point() } // 图片起始坐标 + private val discCenterPoint by lazy { Point() } // 旋转中心坐标 + private var discRotation = 0.0f + + private var needleBitmap = + BitmapFactory.decodeResource(resources, R.drawable.ic_playing_needle) + private val needleMatrix by lazy { Matrix() } + private val needleStartPoint by lazy { Point() } + private val needleCenterPoint by lazy { Point() } + private var needleRotation = NEEDLE_ROTATION_PLAY + + private var coverBitmap: Bitmap? = null + private val coverMatrix by lazy { Matrix() } + private val coverStartPoint by lazy { Point() } + private val coverCenterPoint by lazy { Point() } + private var coverSize = 0 + + private val rotationAnimator by lazy { + ValueAnimator.ofFloat(0f, 360f).apply { + duration = 20000 + repeatCount = ValueAnimator.INFINITE + interpolator = LinearInterpolator() + addUpdateListener(rotationUpdateListener) + } + } + private val playAnimator by lazy { + ValueAnimator.ofFloat(NEEDLE_ROTATION_PAUSE, NEEDLE_ROTATION_PLAY).apply { + duration = 300 + addUpdateListener(animationUpdateListener) + } + } + private val pauseAnimator by lazy { + ValueAnimator.ofFloat(NEEDLE_ROTATION_PLAY, NEEDLE_ROTATION_PAUSE).apply { + duration = 300 + addUpdateListener(animationUpdateListener) + } + } + + private var isPlaying = false + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (w > 0 && h > 0) { + initSize() + } + } + + private fun initSize() { + val unit = width.coerceAtMost(height) / 8 + + needleBitmap = ImageUtils.resizeImage(needleBitmap, unit * 2, (unit * 3.33).toInt()) + needleStartPoint.x = (width / 2 - needleBitmap.width / 5.5f).toInt() + needleStartPoint.y = 0 + needleCenterPoint.x = width / 2 + needleCenterPoint.y = (needleBitmap.width / 5.5f).toInt() + + discBitmap = ImageUtils.resizeImage(discBitmap, unit * 6, unit * 6) + val discOffsetY = (needleBitmap.height / 1.5).toInt() + discStartPoint.x = (width - discBitmap.width) / 2 + discStartPoint.y = discOffsetY + discCenterPoint.x = width / 2 + discCenterPoint.y = discBitmap.height / 2 + discOffsetY + + coverSize = unit * 4 + coverStartPoint.x = (width - coverSize) / 2 + coverStartPoint.y = discOffsetY + (discBitmap.height - coverSize) / 2 + coverCenterPoint.x = discCenterPoint.x + coverCenterPoint.y = discCenterPoint.y + } + + override fun onDraw(canvas: Canvas) { + // 1.绘制封面 + val cover = coverBitmap + if (cover != null) { + coverMatrix.setRotate( + discRotation, + coverCenterPoint.x.toFloat(), + coverCenterPoint.y.toFloat() + ) + coverMatrix.preTranslate(coverStartPoint.x.toFloat(), coverStartPoint.y.toFloat()) + coverMatrix.preScale( + coverSize.toFloat() / cover.width, + coverSize.toFloat() / cover.height + ) + canvas.drawBitmap(cover, coverMatrix, null) + } + + // 2.绘制黑胶唱片外侧半透明边框 + coverBorder.setBounds( + discStartPoint.x - COVER_BORDER_WIDTH, + discStartPoint.y - COVER_BORDER_WIDTH, + discStartPoint.x + discBitmap.width + COVER_BORDER_WIDTH, + discStartPoint.y + discBitmap.height + COVER_BORDER_WIDTH + ) + coverBorder.draw(canvas) + + // 3.绘制黑胶 + // 设置旋转中心和旋转角度,setRotate和preTranslate顺序很重要 + discMatrix.setRotate( + discRotation, + discCenterPoint.x.toFloat(), + discCenterPoint.y.toFloat() + ) + // 设置图片起始坐标 + discMatrix.preTranslate(discStartPoint.x.toFloat(), discStartPoint.y.toFloat()) + canvas.drawBitmap(discBitmap, discMatrix, null) + + // 4.绘制指针 + needleMatrix.setRotate( + needleRotation, + needleCenterPoint.x.toFloat(), + needleCenterPoint.y.toFloat() + ) + needleMatrix.preTranslate(needleStartPoint.x.toFloat(), needleStartPoint.y.toFloat()) + canvas.drawBitmap(needleBitmap, needleMatrix, null) + } + + fun initNeedle(isPlaying: Boolean) { + needleRotation = if (isPlaying) NEEDLE_ROTATION_PLAY else NEEDLE_ROTATION_PAUSE + invalidate() + } + + fun setCoverBitmap(bitmap: Bitmap) { + coverBitmap = bitmap + invalidate() + } + + fun start() { + if (isPlaying) { + return + } + isPlaying = true + rotationAnimator.startOrResume() + playAnimator.start() + } + + fun pause() { + if (!isPlaying) { + return + } + isPlaying = false + rotationAnimator.pause() + pauseAnimator.start() + } + + fun reset() { + isPlaying = false + discRotation = 0.0f + rotationAnimator.cancel() + invalidate() + } + + private val rotationUpdateListener = AnimatorUpdateListener { animation -> + discRotation = animation.animatedValue as Float + invalidate() + } + + private val animationUpdateListener = AnimatorUpdateListener { animation -> + needleRotation = animation.animatedValue as Float + invalidate() + } + + companion object { + private const val NEEDLE_ROTATION_PLAY = 0.0f + private const val NEEDLE_ROTATION_PAUSE = -25.0f + + private val COVER_BORDER_WIDTH = SizeUtils.dp2px(6f) + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/widget/MaxHeightLinearLayout.kt b/AAmusic/app/src/main/java/me/wcy/music/widget/MaxHeightLinearLayout.kt new file mode 100644 index 0000000..c3123f2 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/widget/MaxHeightLinearLayout.kt @@ -0,0 +1,39 @@ +package me.wcy.music.widget + +import android.content.Context +import android.util.AttributeSet +import android.widget.LinearLayout +import me.wcy.music.R + +/** + * + */ +class MaxHeightLinearLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + private var maxHeight: Int = 0 + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightLinearLayout) + maxHeight = ta.getDimensionPixelSize(R.styleable.MaxHeightLinearLayout_maxHeight, 0) + ta.recycle() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (maxHeight <= 0) { + return super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + val maxHeightMeasureSpec = MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) + super.onMeasure(widthMeasureSpec, maxHeightMeasureSpec) + } + + /** + * 设置最大高度,单位为px + */ + fun setMaxHeight(maxHeight: Int) { + this.maxHeight = maxHeight + requestLayout() + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/widget/PlayBar.kt b/AAmusic/app/src/main/java/me/wcy/music/widget/PlayBar.kt new file mode 100644 index 0000000..7f70a8c --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/widget/PlayBar.kt @@ -0,0 +1,123 @@ +package me.wcy.music.widget + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.animation.LinearInterpolator +import android.widget.FrameLayout +import androidx.core.text.buildSpannedString +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import me.wcy.music.R +import me.wcy.music.consts.RoutePath +import me.wcy.music.databinding.LayoutPlayBarBinding +import me.wcy.music.main.playlist.CurrentPlaylistFragment +import me.wcy.music.service.PlayServiceModule.playerController +import me.wcy.music.utils.getDuration +import me.wcy.music.utils.getSmallCover +import me.wcy.router.CRouter +import top.wangchenyan.common.CommonApp +import top.wangchenyan.common.ext.findActivity +import top.wangchenyan.common.ext.findLifecycleOwner +import top.wangchenyan.common.ext.getColor +import top.wangchenyan.common.ext.loadAvatar +import top.wangchenyan.common.widget.CustomSpan.appendStyle + +/** + * + */ +class PlayBar @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { + private val viewBinding: LayoutPlayBarBinding + private val playerController by lazy { + CommonApp.app.playerController() + } + private val rotateAnimator: ObjectAnimator + + init { + id = R.id.play_bar + viewBinding = LayoutPlayBarBinding.inflate(LayoutInflater.from(context), this, true) + + rotateAnimator = ObjectAnimator.ofFloat(viewBinding.ivCover, "rotation", 0f, 360f).apply { + duration = 20000 + repeatCount = ValueAnimator.INFINITE + repeatMode = ObjectAnimator.RESTART + interpolator = LinearInterpolator() + } + + initView() + context.findLifecycleOwner()?.let { + initData(it) + } + } + + private fun initView() { + viewBinding.root.setOnClickListener { + CRouter.with(context).url(RoutePath.PLAYING).start() + } + viewBinding.ivPlay.setOnClickListener { + playerController.playPause() + } + viewBinding.ivNext.setOnClickListener { + playerController.next() + } + viewBinding.ivPlaylist.setOnClickListener { + val activity = context.findActivity() + if (activity is FragmentActivity) { + CurrentPlaylistFragment.newInstance() + .show(activity.supportFragmentManager, CurrentPlaylistFragment.TAG) + } + } + } + + private fun initData(lifecycleOwner: LifecycleOwner) { + playerController.currentSong.observe(lifecycleOwner) { currentSong -> + if (currentSong != null) { + isVisible = true + viewBinding.ivCover.loadAvatar(currentSong.mediaMetadata.getSmallCover()) + viewBinding.tvTitle.text = buildSpannedString { + append(currentSong.mediaMetadata.title) + appendStyle( + " - ${currentSong.mediaMetadata.artist}", + color = getColor(R.color.common_text_h2_color) + ) + } + viewBinding.progressBar.max = currentSong.mediaMetadata.getDuration().toInt() + viewBinding.progressBar.progress = playerController.playProgress.value.toInt() + } else { + isVisible = false + } + } + + lifecycleOwner.lifecycleScope.launch { + playerController.playState.collectLatest { playState -> + val isPlaying = playState.isPreparing || playState.isPlaying + viewBinding.ivPlay.isSelected = isPlaying + if (isPlaying) { + if (rotateAnimator.isPaused) { + rotateAnimator.resume() + } else if (rotateAnimator.isStarted.not()) { + rotateAnimator.start() + } + } else { + if (rotateAnimator.isRunning) { + rotateAnimator.pause() + } + } + } + } + + lifecycleOwner.lifecycleScope.launch { + playerController.playProgress.collectLatest { + viewBinding.progressBar.progress = it.toInt() + } + } + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/java/me/wcy/music/widget/loadsir/SoundWaveLoadingCallback.kt b/AAmusic/app/src/main/java/me/wcy/music/widget/loadsir/SoundWaveLoadingCallback.kt new file mode 100644 index 0000000..e13b708 --- /dev/null +++ b/AAmusic/app/src/main/java/me/wcy/music/widget/loadsir/SoundWaveLoadingCallback.kt @@ -0,0 +1,19 @@ +package me.wcy.music.widget.loadsir + +import android.content.Context +import android.view.View +import com.kingja.loadsir.callback.Callback +import me.wcy.music.R + +/** + * + */ +class SoundWaveLoadingCallback : Callback() { + override fun onCreateView(): Int { + return R.layout.load_sir_loading_sound_wave + } + + override fun onReloadEvent(context: Context?, view: View?): Boolean { + return true + } +} \ No newline at end of file diff --git a/AAmusic/app/src/main/res/anim/anim_slide_down.xml b/AAmusic/app/src/main/res/anim/anim_slide_down.xml new file mode 100644 index 0000000..88650fb --- /dev/null +++ b/AAmusic/app/src/main/res/anim/anim_slide_down.xml @@ -0,0 +1,7 @@ + + + + diff --git a/AAmusic/app/src/main/res/anim/anim_slide_up.xml b/AAmusic/app/src/main/res/anim/anim_slide_up.xml new file mode 100644 index 0000000..238dec8 --- /dev/null +++ b/AAmusic/app/src/main/res/anim/anim_slide_up.xml @@ -0,0 +1,7 @@ + + + + diff --git a/AAmusic/app/src/main/res/color/color_main_tab_text_selector.xml b/AAmusic/app/src/main/res/color/color_main_tab_text_selector.xml new file mode 100644 index 0000000..e4e45fa --- /dev/null +++ b/AAmusic/app/src/main/res/color/color_main_tab_text_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/color/color_theme_grey_300.xml b/AAmusic/app/src/main/res/color/color_theme_grey_300.xml new file mode 100644 index 0000000..2812348 --- /dev/null +++ b/AAmusic/app/src/main/res/color/color_theme_grey_300.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/color/color_theme_h2.xml b/AAmusic/app/src/main/res/color/color_theme_h2.xml new file mode 100644 index 0000000..0c89480 --- /dev/null +++ b/AAmusic/app/src/main/res/color/color_theme_h2.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml b/AAmusic/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/AAmusic/app/src/main/res/drawable-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable-anydpi-v26/ic_launcher_round.xml b/AAmusic/app/src/main/res/drawable-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/AAmusic/app/src/main/res/drawable-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable-xhdpi/ic_launcher.webp b/AAmusic/app/src/main/res/drawable-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..467a9b6 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xhdpi/ic_launcher.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xhdpi/ic_launcher_round.webp b/AAmusic/app/src/main/res/drawable-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..467a9b6 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xhdpi/ic_launcher_round.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_default.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_default.webp new file mode 100644 index 0000000..64cea45 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_default.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_default_cover.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_default_cover.webp new file mode 100644 index 0000000..6edd2aa Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_default_cover.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_disc.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_disc.webp new file mode 100644 index 0000000..ed05f79 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/bg_playing_disc.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_default_artist.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_default_artist.webp new file mode 100644 index 0000000..ccbb164 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_default_artist.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_default_cover.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_default_cover.webp new file mode 100644 index 0000000..0b62134 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_default_cover.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_launcher.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..356a825 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_launcher.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_launcher_round.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..356a825 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_launcher_round.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_playing_needle.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_playing_needle.webp new file mode 100644 index 0000000..f9f9c11 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_playing_needle.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_1.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_1.webp new file mode 100644 index 0000000..2992a69 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_1.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_2.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_2.webp new file mode 100644 index 0000000..69989c3 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_2.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_3.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_3.webp new file mode 100644 index 0000000..dfd6c5f Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_3.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_4.webp b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_4.webp new file mode 100644 index 0000000..316002a Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxhdpi/ic_sound_wave_4.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxxhdpi/ic_launcher.webp b/AAmusic/app/src/main/res/drawable-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..f2f8990 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxxhdpi/ic_launcher.webp differ diff --git a/AAmusic/app/src/main/res/drawable-xxxhdpi/ic_launcher_round.webp b/AAmusic/app/src/main/res/drawable-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f2f8990 Binary files /dev/null and b/AAmusic/app/src/main/res/drawable-xxxhdpi/ic_launcher_round.webp differ diff --git a/AAmusic/app/src/main/res/drawable/bg_card.xml b/AAmusic/app/src/main/res/drawable/bg_card.xml new file mode 100644 index 0000000..38372c9 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/bg_card.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/bg_play_bar_progress.xml b/AAmusic/app/src/main/res/drawable/bg_play_bar_progress.xml new file mode 100644 index 0000000..d3a23f0 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/bg_play_bar_progress.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/bg_playing_cover_border.xml b/AAmusic/app/src/main/res/drawable/bg_playing_cover_border.xml new file mode 100644 index 0000000..3207023 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/bg_playing_cover_border.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/bg_playing_playback_progress.xml b/AAmusic/app/src/main/res/drawable/bg_playing_playback_progress.xml new file mode 100644 index 0000000..1c9e79e --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/bg_playing_playback_progress.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/bg_playing_volume_progress.xml b/AAmusic/app/src/main/res/drawable/bg_playing_volume_progress.xml new file mode 100644 index 0000000..a3c0f4a --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/bg_playing_volume_progress.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_arrow_down.xml b/AAmusic/app/src/main/res/drawable/ic_arrow_down.xml new file mode 100644 index 0000000..ba2142a --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_arrow_down.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_arrow_right.xml b/AAmusic/app/src/main/res/drawable/ic_arrow_right.xml new file mode 100644 index 0000000..4932b25 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_arrow_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_close.xml b/AAmusic/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..da93aff --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_delete.xml b/AAmusic/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..a54b7b3 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_discovery_playlist.xml b/AAmusic/app/src/main/res/drawable/ic_discovery_playlist.xml new file mode 100644 index 0000000..fa1fba0 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_discovery_playlist.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_discovery_rank.xml b/AAmusic/app/src/main/res/drawable/ic_discovery_rank.xml new file mode 100644 index 0000000..dd3eef5 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_discovery_rank.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_download.xml b/AAmusic/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..685a6ce --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_favorite.xml b/AAmusic/app/src/main/res/drawable/ic_favorite.xml new file mode 100644 index 0000000..5071dc1 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_favorite.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_favorite_fill.xml b/AAmusic/app/src/main/res/drawable/ic_favorite_fill.xml new file mode 100644 index 0000000..2bcc3c9 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_favorite_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_favorite_selector.xml b/AAmusic/app/src/main/res/drawable/ic_favorite_selector.xml new file mode 100644 index 0000000..fd70774 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_favorite_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_launcher_foreground.xml b/AAmusic/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..07c13e2 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_local_music.xml b/AAmusic/app/src/main/res/drawable/ic_local_music.xml new file mode 100644 index 0000000..35e28ce --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_local_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu.xml b/AAmusic/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..7915d80 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_about.xml b/AAmusic/app/src/main/res/drawable/ic_menu_about.xml new file mode 100644 index 0000000..1ece034 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_about.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_domain.xml b/AAmusic/app/src/main/res/drawable/ic_menu_domain.xml new file mode 100644 index 0000000..af0776a --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_domain.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_exit.xml b/AAmusic/app/src/main/res/drawable/ic_menu_exit.xml new file mode 100644 index 0000000..5da22ad --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_logout.xml b/AAmusic/app/src/main/res/drawable/ic_menu_logout.xml new file mode 100644 index 0000000..c4577b2 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_logout.xml @@ -0,0 +1,10 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_search.xml b/AAmusic/app/src/main/res/drawable/ic_menu_search.xml new file mode 100644 index 0000000..20c7b4e --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_settings.xml b/AAmusic/app/src/main/res/drawable/ic_menu_settings.xml new file mode 100644 index 0000000..95fc144 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_menu_timer.xml b/AAmusic/app/src/main/res/drawable/ic_menu_timer.xml new file mode 100644 index 0000000..b3b5c74 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_menu_timer.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_more.xml b/AAmusic/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 0000000..4714416 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_next.xml b/AAmusic/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 0000000..9d0693f --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,12 @@ + + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_notification.xml b/AAmusic/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..7a0582f --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_notification_next.xml b/AAmusic/app/src/main/res/drawable/ic_notification_next.xml new file mode 100644 index 0000000..a32e2f2 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_notification_next.xml @@ -0,0 +1,12 @@ + + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_notification_pause.xml b/AAmusic/app/src/main/res/drawable/ic_notification_pause.xml new file mode 100644 index 0000000..74a29c8 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_notification_pause.xml @@ -0,0 +1,12 @@ + + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_notification_play.xml b/AAmusic/app/src/main/res/drawable/ic_notification_play.xml new file mode 100644 index 0000000..a11c963 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_notification_play.xml @@ -0,0 +1,12 @@ + + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_pause.xml b/AAmusic/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..91049a3 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_pause_circle.xml b/AAmusic/app/src/main/res/drawable/ic_pause_circle.xml new file mode 100644 index 0000000..fabccea --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_pause_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_play.xml b/AAmusic/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..4c6a158 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_play_bar_play_pause_selector.xml b/AAmusic/app/src/main/res/drawable/ic_play_bar_play_pause_selector.xml new file mode 100644 index 0000000..90b80a7 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play_bar_play_pause_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_play_circle.xml b/AAmusic/app/src/main/res/drawable/ic_play_circle.xml new file mode 100644 index 0000000..d257359 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_play_mode_level_list.xml b/AAmusic/app/src/main/res/drawable/ic_play_mode_level_list.xml new file mode 100644 index 0000000..f650d04 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play_mode_level_list.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_play_mode_loop.xml b/AAmusic/app/src/main/res/drawable/ic_play_mode_loop.xml new file mode 100644 index 0000000..caf3924 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play_mode_loop.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_play_mode_shuffle.xml b/AAmusic/app/src/main/res/drawable/ic_play_mode_shuffle.xml new file mode 100644 index 0000000..b2e3d92 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play_mode_shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_play_mode_single.xml b/AAmusic/app/src/main/res/drawable/ic_play_mode_single.xml new file mode 100644 index 0000000..7b0c7c0 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_play_mode_single.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_playing_play_pause_selector.xml b/AAmusic/app/src/main/res/drawable/ic_playing_play_pause_selector.xml new file mode 100644 index 0000000..274c8b1 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_playing_play_pause_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_playing_playback_progress_thumb.xml b/AAmusic/app/src/main/res/drawable/ic_playing_playback_progress_thumb.xml new file mode 100644 index 0000000..986e73e --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_playing_playback_progress_thumb.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_playing_volume_progress_thumb.xml b/AAmusic/app/src/main/res/drawable/ic_playing_volume_progress_thumb.xml new file mode 100644 index 0000000..385a58c --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_playing_volume_progress_thumb.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_playlist.xml b/AAmusic/app/src/main/res/drawable/ic_playlist.xml new file mode 100644 index 0000000..e080e61 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_playlist.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_previous.xml b/AAmusic/app/src/main/res/drawable/ic_previous.xml new file mode 100644 index 0000000..6e00afb --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_previous.xml @@ -0,0 +1,12 @@ + + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_radio.xml b/AAmusic/app/src/main/res/drawable/ic_radio.xml new file mode 100644 index 0000000..e7e1551 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_radio.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_recommend.xml b/AAmusic/app/src/main/res/drawable/ic_recommend.xml new file mode 100644 index 0000000..d2e06b0 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_recommend.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_sound_wave_animation.xml b/AAmusic/app/src/main/res/drawable/ic_sound_wave_animation.xml new file mode 100644 index 0000000..33dd3c6 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_sound_wave_animation.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_tab_discover.xml b/AAmusic/app/src/main/res/drawable/ic_tab_discover.xml new file mode 100644 index 0000000..27fefc3 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_tab_discover.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_tab_layout_indicator.xml b/AAmusic/app/src/main/res/drawable/ic_tab_layout_indicator.xml new file mode 100644 index 0000000..d9fc788 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_tab_layout_indicator.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/drawable/ic_tab_mine.xml b/AAmusic/app/src/main/res/drawable/ic_tab_mine.xml new file mode 100644 index 0000000..c0932df --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_tab_mine.xml @@ -0,0 +1,9 @@ + + + diff --git a/AAmusic/app/src/main/res/drawable/ic_volume.xml b/AAmusic/app/src/main/res/drawable/ic_volume.xml new file mode 100644 index 0000000..a6f8404 --- /dev/null +++ b/AAmusic/app/src/main/res/drawable/ic_volume.xml @@ -0,0 +1,10 @@ + + + diff --git a/AAmusic/app/src/main/res/layout/activity_about.xml b/AAmusic/app/src/main/res/layout/activity_about.xml new file mode 100644 index 0000000..aef3ea8 --- /dev/null +++ b/AAmusic/app/src/main/res/layout/activity_about.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/AAmusic/app/src/main/res/layout/activity_main.xml b/AAmusic/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..1ef799b --- /dev/null +++ b/AAmusic/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + diff --git a/AAmusic/app/src/main/res/layout/activity_playing.xml b/AAmusic/app/src/main/res/layout/activity_playing.xml new file mode 100644 index 0000000..407c63f --- /dev/null +++ b/AAmusic/app/src/main/res/layout/activity_playing.xml @@ -0,0 +1,328 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/activity_settings.xml b/AAmusic/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..aef3ea8 --- /dev/null +++ b/AAmusic/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/AAmusic/app/src/main/res/layout/dialog_api_domain.xml b/AAmusic/app/src/main/res/layout/dialog_api_domain.xml new file mode 100644 index 0000000..530ec1b --- /dev/null +++ b/AAmusic/app/src/main/res/layout/dialog_api_domain.xml @@ -0,0 +1,35 @@ + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/dialog_song_more_menu.xml b/AAmusic/app/src/main/res/layout/dialog_song_more_menu.xml new file mode 100644 index 0000000..d03e8cf --- /dev/null +++ b/AAmusic/app/src/main/res/layout/dialog_song_more_menu.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/fragment_collect_song.xml b/AAmusic/app/src/main/res/layout/fragment_collect_song.xml new file mode 100644 index 0000000..e465653 --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_collect_song.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/AAmusic/app/src/main/res/layout/fragment_current_playlist.xml b/AAmusic/app/src/main/res/layout/fragment_current_playlist.xml new file mode 100644 index 0000000..55783ef --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_current_playlist.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/AAmusic/app/src/main/res/layout/fragment_discover.xml b/AAmusic/app/src/main/res/layout/fragment_discover.xml new file mode 100644 index 0000000..b3aa5dc --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_discover.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/fragment_local_music.xml b/AAmusic/app/src/main/res/layout/fragment_local_music.xml new file mode 100644 index 0000000..aba3acf --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_local_music.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/fragment_login_route.xml b/AAmusic/app/src/main/res/layout/fragment_login_route.xml new file mode 100644 index 0000000..0f36c22 --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_login_route.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/fragment_mine.xml b/AAmusic/app/src/main/res/layout/fragment_mine.xml new file mode 100644 index 0000000..9979c2f --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_mine.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AAmusic/app/src/main/res/layout/fragment_phone_login.xml b/AAmusic/app/src/main/res/layout/fragment_phone_login.xml new file mode 100644 index 0000000..457fdcf --- /dev/null +++ b/AAmusic/app/src/main/res/layout/fragment_phone_login.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + +