├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── drawable │ │ │ │ ├── bet.png │ │ │ │ ├── bfc.png │ │ │ │ ├── bgm.png │ │ │ │ ├── mall.png │ │ │ │ ├── music.png │ │ │ │ ├── discuss.png │ │ │ │ ├── icon_down.png │ │ │ │ ├── icon_like.png │ │ │ │ ├── icon_look.png │ │ │ │ ├── icon_rank.png │ │ │ │ ├── icon_up.png │ │ │ │ ├── play_all.png │ │ │ │ ├── certificate.png │ │ │ │ ├── icon_daily.png │ │ │ │ ├── icon_music.png │ │ │ │ ├── icon_parise.png │ │ │ │ ├── icon_radio.png │ │ │ │ ├── icon_broadcast.png │ │ │ │ ├── icon_collect.png │ │ │ │ ├── icon_dislike.png │ │ │ │ ├── icon_late_play.png │ │ │ │ ├── icon_playlist.png │ │ │ │ ├── icon_song_left.png │ │ │ │ ├── icon_song_more.png │ │ │ │ ├── icon_song_play.png │ │ │ │ ├── icon_triangle.png │ │ │ │ ├── icon_parise_fill.png │ │ │ │ ├── icon_play_songs.png │ │ │ │ ├── icon_song_pause.png │ │ │ │ ├── icon_song_right.png │ │ │ │ ├── icon_download_black.png │ │ │ │ ├── icon_song_comment.png │ │ │ │ ├── icon_song_download.png │ │ │ │ ├── icon_song_play_type_1.png │ │ │ │ ├── icon_event_video_b_play.png │ │ │ │ └── ic_compose.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ ├── ic_launcher_round.png │ │ │ │ └── ic_launcher_foreground.webp │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── xml │ │ │ │ └── network_security_config.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-v31 │ │ │ │ └── themes.xml │ │ │ ├── anim │ │ │ │ ├── slide_in_down.xml │ │ │ │ └── slide_out_down.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── layout │ │ │ │ └── activity_net_ease_music_splash.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── navigation │ │ │ │ └── nav_graph.xml │ │ ├── proto │ │ │ ├── music_settings.proto │ │ │ └── cookie_store.proto │ │ ├── java │ │ │ └── com │ │ │ │ └── mrlin │ │ │ │ └── composemany │ │ │ │ ├── repository │ │ │ │ ├── entity │ │ │ │ │ ├── LikeList.kt │ │ │ │ │ ├── EmptyResponse.kt │ │ │ │ │ ├── MusicData.kt │ │ │ │ │ ├── MusicUrl.kt │ │ │ │ │ ├── Song.kt │ │ │ │ │ ├── BannerData.kt │ │ │ │ │ ├── MV.kt │ │ │ │ │ ├── LyricData.kt │ │ │ │ │ ├── PlayListData.kt │ │ │ │ │ ├── Album.kt │ │ │ │ │ ├── RecommendData.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ └── CommentData.kt │ │ │ │ ├── db │ │ │ │ │ ├── MusicDatabase.kt │ │ │ │ │ └── UserDao.kt │ │ │ │ ├── store │ │ │ │ │ ├── CookieStoreSerializer.kt │ │ │ │ │ └── MusicSettingsSerializer.kt │ │ │ │ └── NetEaseMusicApi.kt │ │ │ │ ├── ComposeManyApp.kt │ │ │ │ ├── ui │ │ │ │ ├── theme │ │ │ │ │ ├── Color.kt │ │ │ │ │ ├── Shape.kt │ │ │ │ │ ├── Type.kt │ │ │ │ │ └── Theme.kt │ │ │ │ └── component │ │ │ │ │ ├── Layout.kt │ │ │ │ │ └── Panel.kt │ │ │ │ ├── state │ │ │ │ └── ViewState.kt │ │ │ │ ├── utils │ │ │ │ ├── NumberHelper.kt │ │ │ │ ├── ModifierHelper.kt │ │ │ │ └── ViewModelHelper.kt │ │ │ │ ├── pages │ │ │ │ ├── music │ │ │ │ │ ├── MusicSplashActivity.kt │ │ │ │ │ ├── widgets │ │ │ │ │ │ ├── Buttons.kt │ │ │ │ │ │ ├── Avatar.kt │ │ │ │ │ │ ├── CustomBanner.kt │ │ │ │ │ │ ├── PlayWidget.kt │ │ │ │ │ │ ├── PlayListWidget.kt │ │ │ │ │ │ └── CollapsingAppBar.kt │ │ │ │ │ ├── Routes.kt │ │ │ │ │ ├── playsong │ │ │ │ │ │ ├── SongViewModel.kt │ │ │ │ │ │ ├── CommentsViewModel.kt │ │ │ │ │ │ └── PlaySongFragment.kt │ │ │ │ │ ├── playlist │ │ │ │ │ │ └── MusicPlayListViewModel.kt │ │ │ │ │ ├── login │ │ │ │ │ │ └── MusicLogin.kt │ │ │ │ │ ├── home │ │ │ │ │ │ ├── MusicHomeViewModel.kt │ │ │ │ │ │ ├── MusicHomeFragment.kt │ │ │ │ │ │ ├── Mine.kt │ │ │ │ │ │ ├── Discovery.kt │ │ │ │ │ │ └── MusicHome.kt │ │ │ │ │ └── PlaySongsViewModel.kt │ │ │ │ ├── mall │ │ │ │ │ ├── MallScreen.kt │ │ │ │ │ ├── MallActivity.kt │ │ │ │ │ └── category │ │ │ │ │ │ └── MallHome.kt │ │ │ │ └── fund │ │ │ │ │ └── FundViewModel.kt │ │ │ │ ├── net │ │ │ │ ├── PersistCookieJar.kt │ │ │ │ └── PersistentCookieStore.kt │ │ │ │ ├── di │ │ │ │ ├── EnumRetrofitConverterFactory.kt │ │ │ │ └── AppModule.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ └── MainActivity.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── mrlin │ │ │ └── composemany │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── mrlin │ │ └── composemany │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro ├── schemas │ └── com.mrlin.composemany.repository.db.MusicDatabase │ │ └── 1.json └── build.gradle ├── ks └── composemany.jks ├── screenshots ├── lyric.jpg ├── main.png ├── comments.png ├── fund_main.png ├── music_main.jpg ├── play_list.jpeg ├── song_play.png └── floor_comment.jpg ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── settings.gradle ├── gradle.properties ├── .gitignore ├── gradlew.bat ├── README.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release/ -------------------------------------------------------------------------------- /ks/composemany.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/ks/composemany.jks -------------------------------------------------------------------------------- /screenshots/lyric.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/lyric.jpg -------------------------------------------------------------------------------- /screenshots/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/main.png -------------------------------------------------------------------------------- /screenshots/comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/comments.png -------------------------------------------------------------------------------- /screenshots/fund_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/fund_main.png -------------------------------------------------------------------------------- /screenshots/music_main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/music_main.jpg -------------------------------------------------------------------------------- /screenshots/play_list.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/play_list.jpeg -------------------------------------------------------------------------------- /screenshots/song_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/song_play.png -------------------------------------------------------------------------------- /screenshots/floor_comment.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/screenshots/floor_comment.jpg -------------------------------------------------------------------------------- /app/src/main/res/drawable/bet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/bet.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/bfc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/bfc.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/bgm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/bgm.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/mall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/mall.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/drawable/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/music.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/discuss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/discuss.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_down.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_like.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_like.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_look.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_look.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_rank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_rank.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_up.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/play_all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/play_all.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/certificate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/certificate.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_daily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_daily.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_music.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_parise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_parise.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_radio.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_broadcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_broadcast.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_collect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_collect.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_dislike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_dislike.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_late_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_late_play.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_playlist.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_left.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_more.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_play.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_triangle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_triangle.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_parise_fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_parise_fill.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_play_songs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_play_songs.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_pause.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_right.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_download_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_download_black.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_comment.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_download.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_song_play_type_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_song_play_type_1.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_event_video_b_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/drawable/icon_event_video_b_play.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mr-lin930819/ComposeMany/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ComposeMany 3 | NetEaseMusicActivity 4 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/proto/music_settings.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.mrlin.composemany"; 4 | option java_multiple_files = true; 5 | 6 | message MusicSettings { 7 | int64 user_account_id = 1; 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/LikeList.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | data class LikeList( 4 | val ids: List, 5 | val checkPoint: Long, 6 | val code: Int, 7 | ) 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/EmptyResponse.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class EmptyResponse( 6 | @SerializedName("code") 7 | val code: Int = 200 8 | ) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Mar 03 23:40:58 CST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/MusicData.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | data class MusicData( 4 | val mvid: Long, 5 | val picUrl: String? = null, 6 | val songName: String, 7 | val artists: String, 8 | val index: Int? = null, 9 | val musicId: Long = 0, 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ComposeManyApp.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | /********************************* 7 | * 应用 8 | * @author mrlin 9 | * 创建于 2021年08月19日 10 | ******************************** */ 11 | @HiltAndroidApp 12 | class ComposeManyApp: Application() -------------------------------------------------------------------------------- /app/src/main/res/values-v31/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/MusicUrl.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | /********************************* 4 | * 音乐地址 5 | * @author mrlin 6 | * 创建于 2021年08月26日 7 | ******************************** */ 8 | data class MusicUrlData( 9 | val data: List, 10 | ) 11 | 12 | data class MusicUrl( 13 | val url: UrlString, 14 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.colorspace.ColorSpaces 5 | 6 | val Teal200 = Color(0xFF0339DA) 7 | val Blue200 = Color(0xFF81D4FA) 8 | val Blue500 = Color(0xFF2196F3) 9 | val Blue700 = Color(0xFF1976D2) 10 | 11 | val LightGray = Color(0xFFEBEBEB) -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_in_down.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/anim/slide_out_down.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/state/ViewState.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.state 2 | 3 | /********************************* 4 | * 界面状态 5 | * @author mrlin 6 | * 创建于 2021年08月19日 7 | ******************************** */ 8 | abstract class ViewState { 9 | object Normal : ViewState() 10 | 11 | class Busy(val message: String? = null) : ViewState() 12 | 13 | class Error(val reason: String) : ViewState() 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/Song.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class Song( 8 | //歌曲id 9 | val id: Long, 10 | //歌曲名称 11 | val name: String? = null, 12 | //演唱者 13 | val artists: String? = null, 14 | //歌曲图片 15 | val picUrl: String? = null, 16 | ) : Parcelable 17 | -------------------------------------------------------------------------------- /app/src/test/java/com/mrlin/composemany/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/utils/NumberHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.utils 2 | 3 | import java.text.DecimalFormat 4 | 5 | /** 6 | * 数字简略显示文本 7 | */ 8 | internal fun Long.simpleNumText(): String { 9 | return when (this) { 10 | in 0..1_0000 -> this.toString() 11 | in 1_0000..1_0000_0000 -> "${DecimalFormat("0.##").format(this.toDouble() / 1_0000f)}万" 12 | else -> "${DecimalFormat("0.##").format(this.toFloat() / 1_0000_0000f)}亿" 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/proto/cookie_store.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option java_package = "com.mrlin.composemany"; 4 | option java_multiple_files = true; 5 | 6 | message CookieStore { 7 | map cookieCache = 1; 8 | map hosts = 2; 9 | } 10 | 11 | message CookieInfo { 12 | string name = 1; 13 | string value = 2; 14 | int64 expiresAt = 3; 15 | string domain = 4; 16 | string path = 5; 17 | bool secure = 6; 18 | bool httpOnly = 7; 19 | bool hostOnly = 8; 20 | bool persistent = 9; 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/MusicSplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.mrlin.composemany.R 6 | import dagger.hilt.android.AndroidEntryPoint 7 | @AndroidEntryPoint 8 | class MusicSplashActivity : AppCompatActivity() { 9 | 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContentView(R.layout.activity_net_ease_music_splash) 13 | } 14 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { url 'https://maven.aliyun.com/repository/public/' } 4 | maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } 5 | gradlePluginPortal() 6 | google() 7 | mavenCentral() 8 | } 9 | } 10 | dependencyResolutionManagement { 11 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 12 | repositories { 13 | google() 14 | mavenCentral() 15 | } 16 | } 17 | rootProject.name = "ComposeMany" 18 | include ':app' 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/db/MusicDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.db 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.mrlin.composemany.repository.entity.User 6 | 7 | /********************************* 8 | * 音乐功能数据库 9 | * @author mrlin 10 | * 创建于 2021年08月20日 11 | ******************************** */ 12 | @Database(version = 1, exportSchema = true, entities = [ 13 | User::class 14 | ]) 15 | abstract class MusicDatabase : RoomDatabase() { 16 | abstract fun userDao(): UserDao 17 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF81D4FA 7 | #FF2196F3 8 | #FF1976D2 9 | #FF03DAC5 10 | #FF018786 11 | #FF000000 12 | #FFFFFFFF 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/utils/ModifierHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.utils 2 | 3 | import androidx.compose.ui.ExperimentalComposeUiApi 4 | import androidx.compose.ui.Modifier 5 | import androidx.compose.ui.input.key.* 6 | 7 | /********************************* 8 | * Modifier类辅助 9 | * @author mrlin 10 | * 创建于 2021年10月27日 11 | ******************************** */ 12 | @OptIn(ExperimentalComposeUiApi::class) 13 | fun Modifier.onHandleBack(onBackPressed: (() -> Unit)?): Modifier { 14 | return onKeyEvent { 15 | if (it.key == Key.Back && it.type == KeyEventType.KeyUp) { 16 | onBackPressed?.invoke() 17 | } 18 | true 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/db/UserDao.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.db 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.mrlin.composemany.repository.entity.User 8 | 9 | /********************************* 10 | * 用户数据库操作 11 | * @author mrlin 12 | * 创建于 2021年08月20日 13 | ******************************** */ 14 | @Dao 15 | interface UserDao { 16 | @Insert(onConflict = OnConflictStrategy.REPLACE) 17 | suspend fun insert(user: User) 18 | 19 | @Query("SELECT * FROM User WHERE ua_id=(:accountId)") 20 | suspend fun findUser(accountId: Long): User? 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/BannerData.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | /********************************* 4 | * banner数据 5 | * @author mrlin 6 | * 创建于 2021年08月23日 7 | ******************************** */ 8 | data class BannerData( 9 | var banners: List? = null, 10 | var code: Int = 0, 11 | ) { 12 | companion object { 13 | const val TYPE_PC = 0 14 | const val TYPE_ANDROID = 1 15 | const val TYPE_IPHONE = 2 16 | const val TYPE_IPAD = 3 17 | } 18 | 19 | data class Banner( 20 | var pic: String? = null, 21 | var typeTitle: String? = null, 22 | var targetId: Long = 0, 23 | ) 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/mall/MallScreen.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.mall 2 | 3 | import androidx.annotation.DrawableRes 4 | import com.mrlin.composemany.R 5 | 6 | sealed class MallScreen(val route: String, @DrawableRes val iconRes: Int = R.drawable.icon_song_more) { 7 | object Home : MallScreen("mall/home", R.drawable.icon_song_more) { 8 | object Main : MallScreen("mall/home/main") 9 | } 10 | object Category : MallScreen("mall/category", R.drawable.icon_song_download) 11 | object ShopCart : MallScreen("mall/shopCart", R.drawable.icon_song_play_type_1) 12 | object Mine : MallScreen("mall/mine", R.drawable.icon_dislike) 13 | 14 | object Detail: MallScreen("mall/detail") 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/net/PersistCookieJar.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.net 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import com.mrlin.composemany.CookieStore 6 | import okhttp3.Cookie 7 | import okhttp3.CookieJar 8 | import okhttp3.HttpUrl 9 | 10 | /** 11 | * cookie持久化 12 | */ 13 | class PersistCookieJar constructor(cookieStore: DataStore) : CookieJar { 14 | private val cookieStore = PersistentCookieStore(cookieDataStore = cookieStore) 15 | override fun saveFromResponse(url: HttpUrl, cookies: List) { 16 | cookies.forEach { cookieStore.add(url, it) } 17 | } 18 | 19 | override fun loadForRequest(url: HttpUrl): List { 20 | return cookieStore.get(url = url) 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mrlin/composemany/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.mrlin.composemany", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/fund/FundViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.fund 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.launch 9 | import java.text.SimpleDateFormat 10 | import java.util.* 11 | 12 | class FundViewModel : ViewModel() { 13 | private val _time = MutableStateFlow("") 14 | val time: StateFlow = _time 15 | 16 | fun runTimer() = viewModelScope.launch { 17 | while (true) { 18 | delay(1000) 19 | _time.tryEmit(timeFormat.format(Date())) 20 | } 21 | } 22 | 23 | companion object { 24 | val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA) 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/widgets/Buttons.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.widgets 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.compose.material.Icon 5 | import androidx.compose.material.IconButton 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.res.painterResource 10 | 11 | /** 12 | * 界面操作的小按键 13 | */ 14 | @Composable 15 | fun MiniButton( 16 | @DrawableRes iconRes: Int, 17 | modifier: Modifier = Modifier, 18 | tint: Color = Color.LightGray, 19 | onClick: () -> Unit = { } 20 | ) { 21 | IconButton(onClick = onClick, modifier = modifier) { 22 | Icon( 23 | painter = painterResource(id = iconRes), 24 | contentDescription = null, 25 | tint = tint, 26 | ) 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/utils/ViewModelHelper.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.utils 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.mrlin.composemany.state.ViewState 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.launch 9 | 10 | /** 11 | * 进行繁忙任务 12 | */ 13 | internal fun ViewModel.busyWork( 14 | state: MutableStateFlow, 15 | work: suspend CoroutineScope.() -> Any 16 | ) = viewModelScope.launch { 17 | state.emit(ViewState.Busy()) 18 | try { 19 | when (val result = work()) { 20 | is ViewState -> state.tryEmit(result) 21 | else -> state.tryEmit(ViewState.Normal) 22 | } 23 | } catch (t: Throwable) { 24 | state.tryEmit(ViewState.Error(t.message.orEmpty())) 25 | t.printStackTrace() 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/MV.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | /** 4 | * MV数据 5 | */ 6 | data class MVData ( 7 | var updateTime: Long = 0, 8 | var data: List? = null, 9 | var hasMore: Boolean? = null, 10 | var code: Int = 0, 11 | ) { 12 | data class MV ( 13 | var id: Long = 0, 14 | var cover: String? = null, 15 | var name: String? = null, 16 | var playCount: Long = 0, 17 | var briefDesc: String? = null, 18 | var desc: String? = null, 19 | var artistName: String? = null, 20 | var artistId: Long = 0, 21 | var duration: Long = 0, 22 | var mark: Int = 0, 23 | var lastRank: Int = 0, 24 | var score: Int = 0, 25 | var subed: Boolean? = null, 26 | var artists: List? = null, 27 | ) 28 | 29 | data class Artist ( 30 | val id: Long = 0, 31 | val name: String = "" 32 | ) 33 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_net_ease_music_splash.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/Routes.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music 2 | 3 | import androidx.navigation.NavDirections 4 | import com.mrlin.composemany.NavGraphDirections 5 | import com.mrlin.composemany.pages.music.playsong.PlaySongFragmentDirections 6 | import com.mrlin.composemany.repository.entity.Recommend 7 | import com.mrlin.composemany.repository.entity.Song 8 | 9 | /********************************* 10 | * 音乐功能界面路由 11 | * @author mrlin 12 | * 创建于 2021年08月23日 13 | ******************************** */ 14 | abstract class MusicScreen(val directions: NavDirections) { 15 | //歌单列表 16 | class PlayList(recommend: Recommend) : 17 | MusicScreen(NavGraphDirections.toMusicPlayListFragment(recommend)) 18 | 19 | //歌曲播放 20 | class PlaySong : MusicScreen(NavGraphDirections.toPlaySongFragment()) 21 | 22 | //歌曲评论 23 | class SongComment(song: Song) : 24 | MusicScreen(PlaySongFragmentDirections.actionPlaySongFragmentToCommentsFragment(song)) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/store/CookieStoreSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.store 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import com.google.protobuf.InvalidProtocolBufferException 6 | import com.mrlin.composemany.CookieStore 7 | import java.io.InputStream 8 | import java.io.OutputStream 9 | 10 | @Suppress("BlockingMethodInNonBlockingContext") 11 | object CookieStoreSerializer : Serializer { 12 | override suspend fun readFrom(input: InputStream): CookieStore { 13 | try { 14 | return CookieStore.parseFrom(input) 15 | } catch (exception: InvalidProtocolBufferException) { 16 | throw CorruptionException("Cannot read proto.", exception) 17 | } 18 | } 19 | 20 | override suspend fun writeTo(t: CookieStore, output: OutputStream) { 21 | t.writeTo(output) 22 | } 23 | 24 | override val defaultValue: CookieStore 25 | get() = CookieStore.getDefaultInstance() 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ui/component/Layout.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.ui.component 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.shape.CornerSize 5 | import androidx.compose.material.Card 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | 12 | @Composable 13 | fun TitleRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) = Row( 14 | modifier = modifier 15 | .fillMaxWidth() 16 | .padding(8.dp), 17 | horizontalArrangement = Arrangement.SpaceBetween, 18 | verticalAlignment = Alignment.CenterVertically, 19 | content = content 20 | ) 21 | 22 | @Composable 23 | fun TopCard(content: @Composable () -> Unit) = Card( 24 | Modifier 25 | .fillMaxWidth() 26 | .padding(8.dp), 27 | shape = MaterialTheme.shapes.medium.copy(all = CornerSize(10.dp)), 28 | content = content 29 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/widgets/Avatar.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.widgets 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.platform.LocalDensity 8 | import androidx.compose.ui.unit.Dp 9 | import androidx.compose.ui.unit.dp 10 | import coil.compose.rememberImagePainter 11 | import coil.transform.CircleCropTransformation 12 | import com.mrlin.composemany.repository.entity.limitSize 13 | 14 | /********************************* 15 | * 头像 16 | * @author mrlin 17 | * 创建于 2021年09月08日 18 | ******************************** */ 19 | @Composable 20 | fun CircleAvatar(url: String?, size: Dp = 36.dp) { 21 | val sizePx = with(LocalDensity.current) { size.roundToPx() } 22 | Image(painter = rememberImagePainter(url?.limitSize(sizePx), builder = { 23 | transformations(CircleCropTransformation()) 24 | }), contentDescription = null, modifier = Modifier.size(size = size)) 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ui/component/Panel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.ui.component 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.Divider 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.tooling.preview.Preview 12 | import androidx.compose.ui.unit.dp 13 | 14 | @Composable 15 | fun TitleDivider(title: String) { 16 | Row(verticalAlignment = Alignment.CenterVertically) { 17 | Divider(Modifier.weight(1.0f)) 18 | Text( 19 | text = title, 20 | style = MaterialTheme.typography.caption, 21 | modifier = Modifier.padding(8.dp) 22 | ) 23 | Divider(Modifier.weight(1.0f)) 24 | } 25 | } 26 | 27 | @Preview(showBackground = true) 28 | @Composable 29 | fun TitleDividerPreview() { 30 | TitleDivider(title = "测试") 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/LyricData.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import java.util.regex.Pattern 4 | 5 | data class LyricData( 6 | val lrc: Lrc, 7 | ) { 8 | data class Lrc( 9 | val version: Int, 10 | val lyric: String, 11 | ) { 12 | /** 13 | * 解析歌词为列表数据 14 | */ 15 | fun parseToList(): List> { 16 | val lyrics = mutableListOf>() 17 | val pattern = Pattern.compile("\\[(\\d{1,2}):(\\d{1,2}).(\\d{1,3})](.+)") 18 | val matcher = pattern.matcher(lyric) 19 | while (matcher.find()) { 20 | val min = matcher.group(1)?.toIntOrNull() ?: 0 21 | val sec = matcher.group(2)?.toIntOrNull() ?: 0 22 | val mill = "0.${matcher.group(3)}".toFloatOrNull() ?: 0f 23 | val time = min * 60 * 1000 + sec * 1000 + (mill * 1000).toInt() 24 | val text = matcher.group(4).orEmpty() 25 | lyrics.add(time to text) 26 | } 27 | lyrics.sortBy { it.first } 28 | return lyrics 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/di/EnumRetrofitConverterFactory.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.di 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import retrofit2.Converter 5 | import retrofit2.Retrofit 6 | import java.lang.reflect.Type 7 | 8 | /********************************* 9 | * 枚举的序列化 10 | * @author mrlin 11 | * 创建于 2021年09月08日 12 | ******************************** */ 13 | class EnumRetrofitConverterFactory : Converter.Factory() { 14 | override fun stringConverter( 15 | type: Type, 16 | annotations: Array, 17 | retrofit: Retrofit 18 | ): Converter<*, String>? { 19 | if (type is Class<*> && type.isEnum) { 20 | return Converter { value -> getSerializedNameValue(value as Enum<*>) } 21 | } 22 | return null 23 | } 24 | 25 | private fun > getSerializedNameValue(e: E): String { 26 | try { 27 | return e.javaClass.getField(e.name).getAnnotation(SerializedName::class.java)?.value.orEmpty() 28 | } catch (exception: NoSuchFieldException) { 29 | exception.printStackTrace() 30 | } 31 | return "" 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/store/MusicSettingsSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.store 2 | 3 | import androidx.datastore.core.CorruptionException 4 | import androidx.datastore.core.Serializer 5 | import com.google.protobuf.InvalidProtocolBufferException 6 | import com.mrlin.composemany.MusicSettings 7 | import java.io.InputStream 8 | import java.io.OutputStream 9 | 10 | /********************************* 11 | * 12 | * @author mrlin 13 | * 创建于 2021年08月20日 14 | ******************************** */ 15 | @Suppress("BlockingMethodInNonBlockingContext") 16 | object MusicSettingsSerializer: Serializer { 17 | override val defaultValue: MusicSettings 18 | get() = MusicSettings.getDefaultInstance() 19 | 20 | override suspend fun readFrom(input: InputStream): MusicSettings { 21 | try { 22 | return MusicSettings.parseFrom(input) 23 | } catch (exception: InvalidProtocolBufferException) { 24 | throw CorruptionException("Cannot read proto.", exception) 25 | } 26 | } 27 | 28 | override suspend fun writeTo(t: MusicSettings, output: OutputStream) { 29 | t.writeTo(output) 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/playsong/SongViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.playsong 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.mrlin.composemany.repository.NetEaseMusicApi 6 | import com.mrlin.composemany.repository.entity.Song 7 | import com.mrlin.composemany.repository.entity.SongCommentData 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.StateFlow 11 | import kotlinx.coroutines.launch 12 | import retrofit2.await 13 | import javax.inject.Inject 14 | 15 | /** 16 | * 歌曲页面 17 | */ 18 | @HiltViewModel 19 | class SongViewModel @Inject constructor(private val musicApi: NetEaseMusicApi) : ViewModel() { 20 | private val _songComment = MutableStateFlow(SongCommentData()) 21 | 22 | val songComment: StateFlow = _songComment 23 | 24 | fun loadComment(song: Song) = viewModelScope.launch { 25 | try { 26 | val comment = musicApi.songCommentData(song.id, offset = 0, limit = 1).await() 27 | _songComment.value = comment 28 | } catch (t: Throwable) { 29 | 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.graphics.Color 5 | import androidx.compose.ui.text.TextStyle 6 | import androidx.compose.ui.text.font.FontFamily 7 | import androidx.compose.ui.text.font.FontWeight 8 | import androidx.compose.ui.unit.sp 9 | 10 | // Set of Material typography styles to start with 11 | val Typography = Typography( 12 | body1 = TextStyle( 13 | fontFamily = FontFamily.Default, 14 | fontWeight = FontWeight.Normal, 15 | fontSize = 16.sp 16 | ), 17 | caption = TextStyle( 18 | fontWeight = FontWeight.Normal, 19 | fontSize = 12.sp, 20 | letterSpacing = 0.4.sp, 21 | color = Color.Gray 22 | ) 23 | /* Other default text styles to override 24 | button = TextStyle( 25 | fontFamily = FontFamily.Default, 26 | fontWeight = FontWeight.W500, 27 | fontSize = 14.sp 28 | ), 29 | caption = TextStyle( 30 | fontFamily = FontFamily.Default, 31 | fontWeight = FontWeight.Normal, 32 | fontSize = 12.sp 33 | ) 34 | */ 35 | ) -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/PlayListData.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * 播放列表数据 7 | */ 8 | data class PlayListData( 9 | val code: Int, 10 | val playlist: PlayList, 11 | ) 12 | 13 | /** 14 | * 个人歌单 15 | */ 16 | data class MyPlayListData( 17 | val code: Int, 18 | val playlist: List, 19 | ) 20 | 21 | data class PlayList( 22 | val tracks: List, 23 | val creator: Subscribers? = null, 24 | val name: String = "", 25 | val coverImgUrl: String = "", 26 | val trackCount: Int = 0, 27 | val id: Long = 0, 28 | val playCount: Long = 0, 29 | ) { 30 | enum class Op { 31 | @SerializedName("add") 32 | ADD, 33 | @SerializedName("del") 34 | DEL 35 | } 36 | } 37 | 38 | data class Subscribers( 39 | val userId: Long, 40 | ) 41 | 42 | data class Track( 43 | val name: String, 44 | val id: Long, 45 | val mv: Long, 46 | val ar: List, 47 | val al: Al, 48 | val t: Int = 0, 49 | ) { 50 | fun artists() = ar.joinToString("/") { it.name } 51 | } 52 | 53 | data class Ar( 54 | val id: Long, 55 | val name: String, 56 | ) 57 | 58 | data class Al( 59 | val id: Long, 60 | val name: String, 61 | val picUrl: String? = null, 62 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | 9 | private val DarkColorPalette = darkColors( 10 | primary = Blue200, 11 | primaryVariant = Blue700, 12 | secondary = Teal200 13 | ) 14 | 15 | private val LightColorPalette = lightColors( 16 | primary = Blue500, 17 | primaryVariant = Blue700, 18 | secondary = Teal200 19 | 20 | /* Other default colors to override 21 | background = Color.White, 22 | surface = Color.White, 23 | onPrimary = Color.White, 24 | onSecondary = Color.Black, 25 | onBackground = Color.Black, 26 | onSurface = Color.Black, 27 | */ 28 | ) 29 | 30 | @Composable 31 | fun ComposeManyTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 32 | val colors = if (darkTheme) { 33 | DarkColorPalette 34 | } else { 35 | LightColorPalette 36 | } 37 | 38 | MaterialTheme( 39 | colors = colors, 40 | typography = Typography, 41 | shapes = Shapes, 42 | content = content 43 | ) 44 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/playlist/MusicPlayListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.playlist 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import com.mrlin.composemany.repository.NetEaseMusicApi 6 | import com.mrlin.composemany.repository.entity.PlayList 7 | import com.mrlin.composemany.repository.entity.Recommend 8 | import com.mrlin.composemany.state.ViewState 9 | import com.mrlin.composemany.utils.busyWork 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import retrofit2.await 14 | import javax.inject.Inject 15 | 16 | /** 17 | * 歌单 18 | */ 19 | @HiltViewModel 20 | class MusicPlayListViewModel @Inject constructor( 21 | private val musicApi: NetEaseMusicApi, 22 | savedStateHandle: SavedStateHandle 23 | ) : ViewModel() { 24 | private val _playList: MutableStateFlow = MutableStateFlow(ViewState.Normal) 25 | val playList: StateFlow = _playList 26 | 27 | init { 28 | val recommend = savedStateHandle.get("recommend") 29 | busyWork(_playList) { 30 | val playList = musicApi.playListDetail(recommend?.id ?: throw Throwable("无歌单数据")).await().playlist 31 | PlayListState(playList) 32 | } 33 | } 34 | 35 | class PlayListState(val data: PlayList) : ViewState() 36 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 16 | 20 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany 2 | 3 | import androidx.annotation.DrawableRes 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.launch 10 | import java.text.SimpleDateFormat 11 | import java.util.* 12 | 13 | class MainViewModel : ViewModel() { 14 | private val _time = MutableStateFlow("") 15 | val time: StateFlow = _time 16 | 17 | fun runTimer() = viewModelScope.launch { 18 | while (true) { 19 | delay(1000) 20 | _time.tryEmit(timeFormat.format(Date())) 21 | } 22 | } 23 | 24 | fun menuList() = listOf( 25 | MainMenu.Fund(), MainMenu.NetEaseMusic(), MainMenu.Mall() 26 | ) 27 | 28 | companion object { 29 | val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA) 30 | } 31 | } 32 | 33 | /** 34 | * 菜单图标数据来源于: 35 | * 1)[https://www.iconfont.cn/collections/detail?spm=a313x.7781069.1998910419.dc64b3430&cid=32207] 36 | * 2)[https://www.iconfont.cn/collections/detail?spm=a313x.7781069.1998910419.dc64b3430&cid=16724] 37 | * 3)[https://www.iconfont.cn/collections/detail?spm=a313x.7781069.1998910419.dc64b3430&cid=23172] 38 | */ 39 | sealed class MainMenu(val name: String, @DrawableRes val icon: Int = R.drawable.discuss) { 40 | class Fund : MainMenu("基金", R.drawable.discuss) 41 | class NetEaseMusic : MainMenu("音乐", R.drawable.music) 42 | class Mall : MainMenu("商城", R.drawable.mall) 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/Album.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | /** 4 | * 专辑数据 5 | */ 6 | data class AlbumData( 7 | val hasMore: Boolean = false, 8 | val weekData: List? = null, 9 | val monthData: List? = null, 10 | val code: Int = 0 11 | ) 12 | 13 | data class Album( 14 | var paid: Boolean? = null, 15 | var onSale: Boolean? = null, 16 | var mark: Int = 0, 17 | var artists: List? = null, 18 | var copyrightId: Long = 0, 19 | var artist: Artist? = null, 20 | var picId: Long = 0L, 21 | var publishTime: Long = 0, 22 | var commentThreadId: String? = null, 23 | var briefDesc: String? = null, 24 | var picUrl: String? = null, 25 | var company: String? = null, 26 | var blurPicUrl: String? = null, 27 | var companyId: Long = 0L, 28 | var pic: Long = 0, 29 | var tags: String? = null, 30 | var status: Int = 0, 31 | var subType: String? = null, 32 | var description: String? = null, 33 | var name: String? = null, 34 | var id: Long = 0, 35 | var type: String? = null, 36 | var size: Int = 0, 37 | var picIdStr: String? = null, 38 | ) 39 | 40 | data class Artist( 41 | var img1v1Id: Long = 0L, 42 | var topicPerson: Int = 0, 43 | var picId: Long = 0L, 44 | var albumSize: Int = 0, 45 | var musicSize: Int = 0, 46 | var briefDesc: String? = null, 47 | var followed: Boolean? = null, 48 | var img1v1Url: String? = null, 49 | var trans: String? = null, 50 | var picUrl: String? = null, 51 | var name: String? = null, 52 | var id: Long = 0L, 53 | var img1v1IdStr: String? = null, 54 | var transNames: List? = null, 55 | ) -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 21 | 22 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/RecommendData.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | /** 7 | * 推荐歌单 8 | */ 9 | data class RecommendData( 10 | val code: Int, 11 | val featureFirst: Boolean, 12 | val haveRcmdSongs: Boolean, 13 | val recommend: List, 14 | ) 15 | 16 | @Parcelize 17 | data class Recommend( 18 | val id: Long = 0, 19 | val type: Int = 0, 20 | val name: String, 21 | val copywriter: String? = null, 22 | val picUrl: UrlString, 23 | val playcount: Long, 24 | val createTime: Long = 0, 25 | val creator: Creator? = null, 26 | val trackCount: Int? = null, 27 | val userId: Long = 0, 28 | val alg: String? = null, 29 | ) : Parcelable 30 | 31 | @Parcelize 32 | data class Creator( 33 | val remarkName: String? = null, 34 | val mutual: Boolean, 35 | val avatarImgId: Long, 36 | val backgroundImgId: Long, 37 | val detailDescription: String, 38 | val defaultAvatar: Boolean, 39 | val expertTags: List? = null, 40 | val djStatus: Int, 41 | val followed: Boolean, 42 | val backgroundUrl: String, 43 | val backgroundImgIdStr: String, 44 | val avatarImgIdStr: String, 45 | val accountStatus: Int, 46 | val userId: Long, 47 | val vipType: Int, 48 | val province: Int, 49 | val avatarUrl: UrlString, 50 | val authStatus: Int, 51 | val userType: Int, 52 | val nickname: String, 53 | val gender: Int, 54 | val birthday: Long, 55 | val city: Int, 56 | val description: String, 57 | val signature: String, 58 | val authority: Int, 59 | ) : Parcelable 60 | 61 | typealias UrlString = String 62 | 63 | fun UrlString.limitSize(width: Int, height: Int = width) = "$this?param=${width}y${height}" -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | #*.jks 56 | #*.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | .cxx/ 61 | 62 | # Google Services (e.g. APIs or Firebase) 63 | # google-services.json 64 | 65 | # Freeline 66 | freeline.py 67 | freeline/ 68 | freeline_project_description.json 69 | 70 | # fastlane 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output 75 | fastlane/readme.md 76 | 77 | # Version control 78 | vcs.xml 79 | 80 | # lint 81 | lint/intermediates/ 82 | lint/generated/ 83 | lint/outputs/ 84 | lint/tmp/ 85 | # lint/reports/ 86 | 87 | app/src/.DS_Store 88 | 89 | .DS_Store 90 | 91 | .idea/ 92 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/User.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import androidx.room.* 4 | 5 | /********************************* 6 | * 用户信息 7 | * @author mrlin 8 | * 创建于 2021年08月19日 9 | ******************************** */ 10 | @Entity( 11 | indices = [ 12 | Index(value = ["ua_id"], unique = true) 13 | ] 14 | ) 15 | data class User( 16 | val loginType: Int, 17 | val code: Int, 18 | @Embedded 19 | val account: Account, 20 | @Embedded 21 | val profile: Profile, 22 | val token: String? = null, 23 | val cookie: String? = null, 24 | @PrimaryKey 25 | var accountId: Long = account.id, 26 | ) { 27 | fun isValid() = code < 299 28 | } 29 | 30 | data class Account( 31 | @ColumnInfo(name = "ua_id") 32 | val id: Long, 33 | @ColumnInfo(name = "ua_user_name") 34 | val userName: String, 35 | @ColumnInfo(name = "ua_type") 36 | val type: Int, 37 | @ColumnInfo(name = "ua_status") 38 | val status: Int, 39 | @ColumnInfo(name = "ua_whitelist_authority") 40 | val whitelistAuthority: Int, 41 | @ColumnInfo(name = "ua_create_time") 42 | val createTime: Long, 43 | @ColumnInfo(name = "ua_salt") 44 | val salt: String, 45 | @ColumnInfo(name = "ua_token_version") 46 | val tokenVersion: Int, 47 | @ColumnInfo(name = "ua_ban") 48 | val ban: Long, 49 | @ColumnInfo(name = "ua_baoyue_version") 50 | val baoyueVersion: Int, 51 | @ColumnInfo(name = "ua_donate_version") 52 | val donateVersion: Int, 53 | @ColumnInfo(name = "ua_vip_type") 54 | val vipType: Int, 55 | @ColumnInfo(name = "ua_viptype_version") 56 | val viptypeVersion: Long, 57 | @ColumnInfo(name = "ua_anonimous_user") 58 | val anonimousUser: Boolean, 59 | ) 60 | 61 | data class Profile( 62 | @ColumnInfo(name = "up_avatarImgIdStr") 63 | val avatarImgIdStr: String, 64 | @ColumnInfo(name = "up_nickname") 65 | val nickname: String, 66 | @ColumnInfo(name = "up_avatarUrl") 67 | val avatarUrl: String, 68 | @ColumnInfo(name = "up_signature") 69 | val signature: String? = null, 70 | ) -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 14 | 17 | 18 | 19 | 24 | 28 | 33 | 34 | 35 | 40 | 44 | 47 | 48 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/widgets/CustomBanner.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.widgets 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.unit.dp 14 | import coil.compose.rememberImagePainter 15 | import com.google.accompanist.pager.ExperimentalPagerApi 16 | import com.google.accompanist.pager.HorizontalPager 17 | import com.google.accompanist.pager.HorizontalPagerIndicator 18 | import com.google.accompanist.pager.rememberPagerState 19 | import com.google.accompanist.placeholder.PlaceholderHighlight 20 | import com.google.accompanist.placeholder.material.placeholder 21 | import com.google.accompanist.placeholder.material.shimmer 22 | 23 | /********************************* 24 | * 自定义banner 25 | * @author mrlin 26 | * 创建于 2021年08月23日 27 | ******************************** */ 28 | @OptIn(ExperimentalPagerApi::class) 29 | @Composable 30 | fun CustomBanner(urls: List, height: Int, onTap: (Int) -> Unit) { 31 | val pagerState = rememberPagerState() 32 | Box( 33 | Modifier 34 | .height(height.dp) 35 | .fillMaxWidth() 36 | .padding(8.dp) 37 | .placeholder(urls.isEmpty(), highlight = PlaceholderHighlight.shimmer()) 38 | ) { 39 | HorizontalPager(state = pagerState, count = urls.size) { page -> 40 | Image( 41 | painter = rememberImagePainter(urls[page]), 42 | contentDescription = null, 43 | modifier = Modifier 44 | .fillMaxWidth() 45 | .clickable { onTap(page) } 46 | ) 47 | } 48 | HorizontalPagerIndicator( 49 | pagerState = pagerState, 50 | modifier = Modifier 51 | .padding(8.dp) 52 | .align(Alignment.BottomCenter), 53 | activeColor = Color.White, inactiveColor = Color.LightGray 54 | ) 55 | } 56 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/login/MusicLogin.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.login 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.foundation.text.KeyboardOptions 5 | import androidx.compose.material.* 6 | import androidx.compose.runtime.* 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.text.TextStyle 10 | import androidx.compose.ui.text.input.KeyboardType 11 | import androidx.compose.ui.text.input.PasswordVisualTransformation 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import com.mrlin.composemany.pages.music.home.MusicHomeViewModel 16 | import com.mrlin.composemany.utils.onHandleBack 17 | 18 | /********************************* 19 | * 音乐登录界面 20 | * @author mrlin 21 | * 创建于 2021年08月19日 22 | ******************************** */ 23 | @Composable 24 | fun MusicLogin(vm: MusicHomeViewModel? = null, onQuit: (() -> Unit)? = null) { 25 | Scaffold(topBar = { 26 | TopAppBar(title = { Text(text = "手机号登录") }) 27 | }) { padding -> 28 | var phone by remember { mutableStateOf("") } 29 | var password by remember { mutableStateOf("") } 30 | Column(modifier = Modifier.padding(padding).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { 31 | Text(text = "欢迎使用云音乐", style = TextStyle(fontSize = 34.sp)) 32 | Spacer(modifier = Modifier.height(30.dp)) 33 | OutlinedTextField(value = phone, 34 | label = { Text(text = "手机", style = MaterialTheme.typography.body1) }, 35 | singleLine = true, 36 | keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), 37 | onValueChange = { phone = it }, 38 | modifier = Modifier.onHandleBack(onQuit)) 39 | Spacer(modifier = Modifier.height(10.dp)) 40 | OutlinedTextField(value = password, 41 | label = { Text(text = "密码", style = MaterialTheme.typography.body1) }, 42 | singleLine = true, 43 | visualTransformation = PasswordVisualTransformation(), 44 | onValueChange = { password = it }, 45 | modifier = Modifier.onHandleBack(onQuit)) 46 | Spacer(modifier = Modifier.height(50.dp)) 47 | Button(onClick = { vm?.login(phone, password) }, modifier = Modifier.fillMaxWidth(0.5f)) { 48 | Text(text = "提交") 49 | } 50 | } 51 | } 52 | } 53 | 54 | @Preview 55 | @Composable 56 | private fun MusicLoginPagePreview() { 57 | MusicLogin() 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/entity/CommentData.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository.entity 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * 歌曲评论 7 | */ 8 | data class SongCommentData( 9 | val isMusician: Boolean = false, 10 | val userId: Long = 0, 11 | val code: Int = 0, 12 | val total: Int = 0, 13 | val more: Boolean = false, 14 | val comments: List = emptyList(), 15 | val topComments: List = emptyList(), 16 | val hotComments: List = emptyList(), 17 | ) 18 | 19 | data class CommentResponse( 20 | val code: Int = 0, 21 | val data: CommentData, 22 | ) 23 | 24 | data class CommentOpResponse( 25 | val code: Int = 0, 26 | val comment: Comment, 27 | ) 28 | 29 | data class FloorCommentResponse( 30 | val code: Int = 0, 31 | val data: FloorCommentData, 32 | ) 33 | 34 | data class CommentData( 35 | val totalCount: Int = 0, 36 | val hasMore: Boolean = false, 37 | val comments: List = emptyList(), 38 | val cursor: Any? = null, 39 | ) { 40 | enum class Type { 41 | @SerializedName("0") 42 | SONG, 43 | 44 | @SerializedName("1") 45 | MV, 46 | 47 | @SerializedName("2") 48 | PLAY_LIST, 49 | } 50 | 51 | enum class SortType { 52 | @SerializedName("1") 53 | RECOMMEND, 54 | 55 | @SerializedName("2") 56 | HOT, 57 | 58 | @SerializedName("3") 59 | NEWEST, 60 | } 61 | 62 | enum class Op { 63 | @SerializedName("1") 64 | PUBLISH, 65 | 66 | @SerializedName("2") 67 | REPLY, 68 | 69 | @SerializedName("0") 70 | DELETE, 71 | } 72 | } 73 | 74 | /** 75 | * 楼层评论 76 | */ 77 | data class FloorCommentData( 78 | val hasMore: Boolean = false, 79 | val totalCount: Int = 0, 80 | val time: Long = 0L, 81 | var comments: List = emptyList(), 82 | var ownerComment: Comment? = null, 83 | ) 84 | 85 | data class Comment( 86 | val user: CommentUser, 87 | val content: String = "", 88 | val time: Long = 0, 89 | var likedCount: Int = 0, 90 | val showFloorComment: FloorComment? = null, 91 | val tag: Tag? = null, 92 | val commentId: Long = 0L, 93 | val beReplied: List? = null, 94 | var liked: Boolean = false, 95 | ) { 96 | data class Tag( 97 | val datas: List? = null, 98 | ) 99 | 100 | data class TagData( 101 | val text: String = "", 102 | ) 103 | } 104 | 105 | data class FloorComment( 106 | var replyCount: Long = 0, 107 | val showReplyCount: Boolean = false, 108 | ) 109 | 110 | data class CommentUser( 111 | val nickname: String = "", 112 | val userId: Long = 0, 113 | val avatarUrl: String? = null, 114 | ) 115 | 116 | /** 117 | * 引用回复 118 | */ 119 | data class BeReplied( 120 | val user: CommentUser, 121 | val content: String? = null, 122 | val status: Int = 0, 123 | val beRepliedCommentId: Long = 0, 124 | ) 125 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/mall/MallActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.mall 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material.BottomNavigation 8 | import androidx.compose.material.Scaffold 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.navigation.NavController 13 | import androidx.navigation.NavGraph.Companion.findStartDestination 14 | import androidx.navigation.compose.NavHost 15 | import androidx.navigation.compose.composable 16 | import androidx.navigation.compose.rememberNavController 17 | import com.mrlin.composemany.pages.mall.category.MallHome 18 | import com.mrlin.composemany.pages.music.widgets.MiniButton 19 | import com.mrlin.composemany.ui.theme.ComposeManyTheme 20 | 21 | /********************************* 22 | * 商城功能 23 | * @author mrlin 24 | * 创建于 2021年10月28日 25 | ******************************** */ 26 | class MallActivity : ComponentActivity() { 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | setContent { 30 | ComposeManyTheme { 31 | val navController = rememberNavController() 32 | NavHost(navController = navController, startDestination = "main") { 33 | composable("main") { PageContent(navController = navController) } 34 | composable(MallScreen.Detail.route) { 35 | Text(text = "详情") 36 | } 37 | } 38 | } 39 | } 40 | } 41 | 42 | } 43 | 44 | /** 45 | * 内容页 46 | */ 47 | @Composable 48 | private fun PageContent(navController: NavController) { 49 | val bottomMenus = listOf(MallScreen.Home, MallScreen.Category, MallScreen.ShopCart, MallScreen.Mine) 50 | //用于底部导航 51 | val bottomNavController = rememberNavController() 52 | Scaffold(bottomBar = { 53 | BottomNavigation { 54 | bottomMenus.forEach { 55 | MiniButton(iconRes = it.iconRes) { 56 | bottomNavController.navigate(it.route) { 57 | popUpTo(bottomNavController.graph.findStartDestination().id) { saveState = true } 58 | launchSingleTop = true 59 | restoreState = true 60 | } 61 | } 62 | } 63 | } 64 | }) { 65 | NavHost(navController = bottomNavController, startDestination = MallScreen.Home.route, Modifier.padding(it)) { 66 | composable(MallScreen.Home.route) { MallHome(navController = navController) } 67 | composable(MallScreen.Category.route) { 68 | Text(text = "分类") 69 | } 70 | composable(MallScreen.ShopCart.route) { 71 | Text(text = "购物车") 72 | } 73 | composable(MallScreen.Mine.route) { 74 | Text(text = "我的") 75 | } 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/widgets/PlayWidget.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.widgets 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.border 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.layout.* 8 | import androidx.compose.material.CircularProgressIndicator 9 | import androidx.compose.material.Icon 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.collectAsState 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.res.painterResource 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | import androidx.lifecycle.viewmodel.compose.viewModel 21 | import coil.compose.rememberImagePainter 22 | import com.mrlin.composemany.R 23 | import com.mrlin.composemany.pages.music.PlaySongsViewModel 24 | 25 | /** 26 | * 页面下面的播放条 27 | */ 28 | @Composable 29 | fun PlayWidget(viewModel: PlaySongsViewModel = viewModel(), height: Dp = 72.dp, onClick: () -> Unit) { 30 | val allSongs by viewModel.allSongs.collectAsState() 31 | val curSong by viewModel.curSong.collectAsState() 32 | Box( 33 | modifier = Modifier 34 | .height(height) 35 | .fillMaxWidth() 36 | .background(color = Color.White) 37 | .border(1.dp, color = Color.LightGray) 38 | .padding(8.dp) 39 | .clickable(onClick = onClick) 40 | ) { 41 | if (allSongs.isEmpty()) { 42 | Text(text = "暂无正在播放的歌曲", modifier = Modifier.align(Alignment.Center)) 43 | } else { 44 | val curProgress by viewModel.curProgress.collectAsState() 45 | val isPlaying by viewModel.isPlaying.collectAsState() 46 | Row(verticalAlignment = Alignment.CenterVertically) { 47 | Image( 48 | painter = rememberImagePainter(curSong?.picUrl.orEmpty()), 49 | contentDescription = null 50 | ) 51 | Spacer(modifier = Modifier.width(8.dp)) 52 | Column { 53 | Text(text = curSong?.name.orEmpty(), maxLines = 1) 54 | Text(text = curSong?.artists.orEmpty()) 55 | } 56 | Spacer(modifier = Modifier.weight(1.0f)) 57 | Box(modifier = Modifier.size(42.dp)) { 58 | Icon( 59 | painter = painterResource(if (isPlaying) R.drawable.icon_song_pause else R.drawable.icon_song_play), 60 | contentDescription = null, 61 | Modifier 62 | .fillMaxSize() 63 | .clickable { 64 | viewModel.togglePlay() 65 | } 66 | ) 67 | CircularProgressIndicator( 68 | progress = curProgress, 69 | modifier = Modifier.padding(5.dp), 70 | strokeWidth = 2.dp 71 | ) 72 | } 73 | } 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/mall/category/MallHome.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.mall.category 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyColumn 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.Icon 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.draw.clip 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.unit.dp 16 | import androidx.navigation.NavController 17 | import androidx.navigation.NavGraphBuilder 18 | import androidx.navigation.compose.composable 19 | import androidx.navigation.navigation 20 | import com.mrlin.composemany.pages.mall.MallScreen 21 | import com.mrlin.composemany.ui.theme.LightGray 22 | import compose.icons.FontAwesomeIcons 23 | import compose.icons.fontawesomeicons.Solid 24 | import compose.icons.fontawesomeicons.solid.MapMarked 25 | import me.onebone.toolbar.CollapsingToolbarScaffold 26 | import me.onebone.toolbar.ScrollStrategy 27 | import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState 28 | 29 | /********************************* 30 | * 首页 31 | * @author mrlin 32 | * 创建于 2021年10月28日 33 | ******************************** */ 34 | fun NavGraphBuilder.mallHome(navController: NavController) { 35 | navigation(startDestination = MallScreen.Home.Main.route, route = MallScreen.Home.route) { 36 | composable(MallScreen.Home.Main.route) { 37 | Text(text = "主页") 38 | } 39 | } 40 | } 41 | 42 | @Composable 43 | fun MallHome(navController: NavController) { 44 | val state = rememberCollapsingToolbarScaffoldState() 45 | CollapsingToolbarScaffold( 46 | modifier = Modifier.fillMaxSize(), 47 | state = state, 48 | scrollStrategy = ScrollStrategy.ExitUntilCollapsed, 49 | toolbar = { 50 | val toolbarState = state.toolbarState 51 | Box( 52 | modifier = Modifier 53 | .height(96.dp) 54 | .fillMaxWidth() 55 | .background(MaterialTheme.colors.primary) 56 | ) { 57 | Icon( 58 | imageVector = FontAwesomeIcons.Solid.MapMarked, contentDescription = null, 59 | modifier = Modifier 60 | .padding(8.dp) 61 | .size(24.dp), tint = Color.White 62 | ) 63 | } 64 | Box( 65 | modifier = Modifier 66 | .road(Alignment.TopStart, Alignment.BottomCenter) 67 | .height(48.dp) 68 | .fillMaxWidth(0.6f + toolbarState.progress * 0.3f) 69 | .padding(top = 8.dp, end = 8.dp, bottom = 8.dp, start = (8 + (1 - toolbarState.progress) * 32f).dp) 70 | .clip(RoundedCornerShape(percent = 50)) 71 | .background(LightGray), 72 | ) { 73 | Text(text = "推广内容", modifier = Modifier.align(Alignment.Center), color = Color.Gray) 74 | } 75 | } 76 | ) { 77 | LazyColumn(modifier = Modifier.fillMaxSize()) { 78 | items(60) { 79 | Text(text = it.toString()) 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ComposeMany 2 | 使用jetpack compose构建的app 3 | 4 | **项目仅供学习,不做商业用途。** 5 | 6 | [TOC] 7 | 8 | 主页面实现的界面有两项: 9 | 10 | 主界面 11 | 12 | ### 音乐 13 | 14 | - 音乐功能借鉴了Flutter项目:[Flutter 版本的网易云音乐 ](https://github.com/fluttercandies/NeteaseCloudMusic) 15 | 16 | #### 功能实现分析 17 | 18 | **在掘金写了篇简单的文章概述了音乐功能的实现思路:** 19 | 20 | **[用Compose实现轻量版网易云音乐 - 掘金 (juejin.cn)](https://juejin.cn/post/7011895995722121247)** 21 | 22 | #### 关于服务端 23 | 24 | 音乐API使用: [Binaryify/NeteaseCloudMusicApi: 网易云音乐 Node.js API service (github.com)](https://github.com/Binaryify/NeteaseCloudMusicApi),使用Vercel构建。 25 | 26 | **可以按照链接仓库中方法搭建自己的Vercel服务器,获得域名。项目中域名统一在AppModule.kt文件中提供:** 27 | 28 | ```kotlin 29 | //com.mrlin.composemany.di.AppModule.kt 30 | @Singleton 31 | @NetEaseMusicRetrofit 32 | @Provides 33 | fun provideNetEaseMusicRetrofit( 34 | cookieDataStore: DataStore 35 | ): Retrofit = Retrofit.Builder() 36 | .baseUrl("https://你的专属Vercel站点域名.vercel.app/") 37 | ... ... 38 | .build() 39 | ``` 40 | 41 | #### 部分界面效果图 42 | 43 | #### 功能 44 | 45 | - 用户登录(手机号+密码方式) 46 | - 推荐歌单、个人歌单列表获取 47 | - 歌单内歌曲的播放 48 | - 歌曲评论、楼层回复评论显示 49 | 50 | music_main play_list song_play lyric 51 | comments floor_comment 52 | 53 | #### 待实现功能 54 | 55 | - [x] 评论点赞 56 | - [x] 歌曲喜欢 57 | - [x] 歌曲评论、评论回复 58 | - [ ] 楼层内回复 59 | - [x] 歌曲歌词显示 60 | - [ ] 精确当前句位置显示、滚动 61 | - [ ] 本地音乐 62 | - [ ] 歌曲缓存 63 | - [ ] 通知栏 64 | - [ ] 桌面小组件 65 | - [ ] 歌曲详细操作(信息、收藏等) 66 | - [ ] 首页轮播、“每日推荐”、“歌单“、”排行榜“、”电台“、”直播“等入口功能 67 | 68 | #### 使用到的架构组件 69 | 70 | | 架构组件 | 用途 | 71 | | ------------------------- | ---------------------- | 72 | | Hilt | 全应用的实例依赖管理 | 73 | | ViewModel | 视图数据以及状态的管理 | 74 | | Navigation | 页面跳转管理 | 75 | | Room | 数据库访问 | 76 | | Paging 3 | 分页数据载入 | 77 | | datastore(protobuf实现) | 参数保存 | 78 | | splashscreen | 启动屏适配 | 79 | 80 | #### 使用到的其他第三方库 81 | 82 | | 名称 | 用途 | 83 | | --------------- | ------------------------------------------------------------ | 84 | | Accompanist | 提供Compose下的ViewPager、骨架屏、状态栏操作等 | 85 | | Retrofit | RESTful API接口通讯实现 | 86 | | Coil | kotlin图片加载框架 | 87 | | toolbar-compose | 实现折叠工具栏,源地址:[onebone/compose-collapsing-toolbar](https://github.com/onebone/compose-collapsing-toolbar) | 88 | 89 | 90 | ### 基金 91 | 92 | 基金页面仿支付宝基金功能,仅作练习,**无任何实际功能** 93 | 94 | fund_main 95 | 96 | 97 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/widgets/PlayListWidget.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.widgets 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.clip 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.res.painterResource 14 | import androidx.compose.ui.text.font.FontWeight 15 | import androidx.compose.ui.text.style.TextOverflow 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import coil.compose.rememberImagePainter 20 | import com.mrlin.composemany.R 21 | import com.mrlin.composemany.repository.entity.limitSize 22 | import com.mrlin.composemany.utils.simpleNumText 23 | 24 | @Composable 25 | fun PlayListWidget( 26 | text: String, picUrl: String? = null, subText: String? = null, playCount: Long? = null, 27 | maxLines: Int? = null, index: Int? = null, onTap: (() -> Unit)? 28 | ) { 29 | Column( 30 | Modifier 31 | .width(112.dp) 32 | .wrapContentHeight() 33 | .clickable(onTap != null, onClick = { onTap?.invoke() }), 34 | ) { 35 | val lines = maxLines ?: Int.MAX_VALUE 36 | val overflow = if (maxLines != null) TextOverflow.Ellipsis else TextOverflow.Clip 37 | picUrl?.run { PlayListCover(playCount = playCount, url = this) } 38 | index?.run { Text(text = toString()) } 39 | Spacer(modifier = Modifier.height(5.dp)) 40 | Text(text = text, maxLines = lines, overflow = overflow, fontSize = 12.sp) 41 | subText?.run { 42 | Spacer(modifier = Modifier.height(2.dp)) 43 | Text( 44 | text = this, 45 | style = MaterialTheme.typography.caption, 46 | maxLines = lines, 47 | overflow = overflow 48 | ) 49 | } 50 | } 51 | } 52 | 53 | @Composable 54 | fun PlayListCover( 55 | playCount: Long? = null, width: Float = 108f, height: Float = width, radius: Float = 24f, 56 | url: String? = null 57 | ) { 58 | Box(modifier = Modifier.clip(RoundedCornerShape(radius))) { 59 | Image( 60 | painter = rememberImagePainter("${url?.limitSize(width.toInt(), height.toInt())}"), 61 | contentDescription = null, 62 | modifier = Modifier.size(width.dp, height.dp) 63 | ) 64 | playCount?.let { 65 | Row(Modifier.padding(top = 1.dp, end = 3.dp)) { 66 | Image( 67 | painter = painterResource(id = R.drawable.icon_triangle), 68 | contentDescription = null, 69 | modifier = Modifier.size(20.dp, 20.dp) 70 | ) 71 | Text( 72 | text = it.simpleNumText(), 73 | color = Color.White, 74 | fontWeight = FontWeight.W500, 75 | fontSize = 12.sp 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | 82 | @Preview(showBackground = true, backgroundColor = 0xFF00AA00) 83 | @Composable 84 | private fun PlayListPreview() { 85 | PlayListWidget(text = "推荐", subText = "子标题", picUrl = "", playCount = 1000) { 86 | 87 | } 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.di 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.datastore.core.DataStore 6 | import androidx.datastore.dataStore 7 | import androidx.room.Room 8 | import com.mrlin.composemany.CookieStore 9 | import com.mrlin.composemany.MusicSettings 10 | import com.mrlin.composemany.net.PersistCookieJar 11 | import com.mrlin.composemany.repository.NetEaseMusicApi 12 | import com.mrlin.composemany.repository.db.MusicDatabase 13 | import com.mrlin.composemany.repository.store.CookieStoreSerializer 14 | import com.mrlin.composemany.repository.store.MusicSettingsSerializer 15 | import dagger.Module 16 | import dagger.Provides 17 | import dagger.hilt.InstallIn 18 | import dagger.hilt.android.qualifiers.ApplicationContext 19 | import dagger.hilt.components.SingletonComponent 20 | import okhttp3.Interceptor 21 | import okhttp3.OkHttpClient 22 | import okhttp3.Response 23 | import retrofit2.Retrofit 24 | import retrofit2.converter.gson.GsonConverterFactory 25 | import javax.inject.Qualifier 26 | import javax.inject.Singleton 27 | 28 | /********************************* 29 | * 应用依赖模块 30 | * @author mrlin 31 | * 创建于 2021年08月19日 32 | ******************************** */ 33 | @Module 34 | @InstallIn(SingletonComponent::class) 35 | class AppModule { 36 | @Singleton 37 | @NetEaseMusicRetrofit 38 | @Provides 39 | fun provideNetEaseMusicRetrofit( 40 | cookieDataStore: DataStore 41 | ): Retrofit = Retrofit.Builder() 42 | .baseUrl("https://mrlin-netease-cloud-music-api-iota-silk.vercel.app/") 43 | .addConverterFactory(GsonConverterFactory.create()) 44 | .addConverterFactory(EnumRetrofitConverterFactory()) 45 | .client( 46 | OkHttpClient.Builder().cookieJar(PersistCookieJar(cookieDataStore)) 47 | .addInterceptor(OkhttpLogger).build() 48 | ) 49 | .build() 50 | 51 | private object OkhttpLogger : Interceptor { 52 | override fun intercept(chain: Interceptor.Chain): Response { 53 | val request = chain.request() 54 | Log.d("OkhttpLogger", "raw request:${request}") 55 | val response = chain.proceed(request) 56 | Log.d("OkhttpLogger", "raw response:${response.peekBody((1024 * 10).toLong()).string()}") 57 | return response 58 | } 59 | } 60 | 61 | @Singleton 62 | @Provides 63 | fun provideNetEaseMusicApi(@NetEaseMusicRetrofit retrofit: Retrofit): NetEaseMusicApi = 64 | retrofit.create(NetEaseMusicApi::class.java) 65 | 66 | @Singleton 67 | @Provides 68 | fun provideMusicDatabase(@ApplicationContext context: Context): MusicDatabase = 69 | Room.databaseBuilder(context, MusicDatabase::class.java, "net-ease-music").build() 70 | 71 | @Provides 72 | fun provideMusicSettings(@ApplicationContext context: Context): DataStore = 73 | context.musicSettingsDataStore 74 | 75 | @Provides 76 | fun provideCookieStore(@ApplicationContext context: Context): DataStore = 77 | context.cookieStoreDataStore 78 | 79 | private val Context.musicSettingsDataStore: DataStore by dataStore( 80 | fileName = "music_settings.pb", 81 | serializer = MusicSettingsSerializer 82 | ) 83 | 84 | private val Context.cookieStoreDataStore: DataStore by dataStore( 85 | fileName = "cookie_store.pb", 86 | serializer = CookieStoreSerializer 87 | ) 88 | } 89 | 90 | @Qualifier 91 | @Retention(AnnotationRetention.BINARY) 92 | annotation class NetEaseMusicRetrofit -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/widgets/CollapsingAppBar.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.widgets 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.Surface 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.alpha 13 | import androidx.compose.ui.geometry.Offset 14 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 15 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource 16 | import androidx.compose.ui.input.nestedscroll.nestedScroll 17 | import androidx.compose.ui.platform.LocalDensity 18 | import androidx.compose.ui.text.style.TextAlign 19 | import androidx.compose.ui.unit.Dp 20 | import androidx.compose.ui.unit.dp 21 | 22 | /********************************* 23 | * 可折叠工具栏 24 | * @author mrlin 25 | * 创建于 2021年09月03日 26 | ******************************** */ 27 | 28 | /** 29 | * 关于内嵌滚动实现折叠工具栏,参考的官网例子: 30 | * [https://developer.android.google.cn/reference/kotlin/androidx/compose/ui/input/nestedscroll/package-summary] 31 | */ 32 | @Composable 33 | private fun PlayListAppBar( 34 | title: String, expandedHeight: Dp? = null, bottom: (@Composable () -> Unit)? = null, 35 | background: (@Composable BoxScope.() -> Unit)? = null, 36 | content: (@Composable () -> Unit)? = null, 37 | ) { 38 | val toolbarHeight = 48.dp 39 | val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() } 40 | val toolbarOffsetHeightPx = remember { mutableStateOf(0f) } 41 | val expandedHeightPx = 42 | with(LocalDensity.current) { expandedHeight?.roundToPx()?.toFloat() ?: toolbarHeightPx } 43 | val toolbarExpandedHeightPx = remember { mutableStateOf(expandedHeightPx) } 44 | val nestedScrollConnection = remember { 45 | object : NestedScrollConnection { 46 | override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { 47 | val delta = available.y 48 | val newOffset = toolbarOffsetHeightPx.value + delta 49 | val newExpendedHeight = toolbarExpandedHeightPx.value + delta 50 | toolbarExpandedHeightPx.value = 51 | newExpendedHeight.coerceIn(toolbarHeightPx, expandedHeightPx) 52 | toolbarOffsetHeightPx.value = newOffset.coerceIn(-expandedHeightPx, 0f) 53 | return Offset.Zero 54 | } 55 | } 56 | } 57 | val percent = toolbarOffsetHeightPx.value.plus(expandedHeightPx) / expandedHeightPx 58 | val tbHeight = with(LocalDensity.current) { (toolbarExpandedHeightPx.value).toDp() } 59 | Box( 60 | Modifier 61 | .fillMaxSize() 62 | .nestedScroll(nestedScrollConnection) 63 | ) { 64 | content?.invoke() 65 | Surface( 66 | color = MaterialTheme.colors.primary, 67 | modifier = Modifier 68 | // .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }, 69 | ) { 70 | Box { 71 | background?.let { 72 | Box( 73 | modifier = Modifier 74 | .matchParentSize() 75 | .alpha(percent), content = it 76 | ) 77 | } 78 | Column( 79 | modifier = Modifier 80 | .height(tbHeight) 81 | .fillMaxWidth() 82 | ) { 83 | Column( 84 | horizontalAlignment = Alignment.CenterHorizontally, 85 | ) { 86 | //标题栏 87 | Text( 88 | title, 89 | textAlign = TextAlign.Center, 90 | style = MaterialTheme.typography.h6, 91 | modifier = Modifier 92 | .fillMaxSize() 93 | .alpha(1 - percent) 94 | ) 95 | } 96 | Box(modifier = Modifier.weight(1.0f)) 97 | //底栏 98 | bottom?.invoke() 99 | } 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/net/PersistentCookieStore.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.net 2 | 3 | import android.text.TextUtils 4 | import androidx.datastore.core.DataStore 5 | import com.mrlin.composemany.CookieInfo 6 | import com.mrlin.composemany.CookieStore 7 | import kotlinx.coroutines.flow.firstOrNull 8 | import kotlinx.coroutines.runBlocking 9 | import okhttp3.Cookie 10 | import okhttp3.HttpUrl 11 | import java.util.concurrent.ConcurrentHashMap 12 | 13 | class PersistentCookieStore(private val cookieDataStore: DataStore) { 14 | //根据各自的业务形态进行定制,可以使用hashMap,甚至也可以选用其他数据结构存储Cookie。例子中使用了HashMap实现,key作为一级域名;value则是以cookieToken为key的Cookie映射,cookieToken的获取见下述方法。 15 | private val cookies: MutableMap> 16 | 17 | /** 18 | * cookieToken的获取 19 | */ 20 | private fun getCookieToken(cookie: Cookie): String { 21 | return cookie.name() + "@" + cookie.domain() 22 | } 23 | 24 | fun add(url: HttpUrl, cookie: Cookie) { 25 | val name = getCookieToken(cookie) 26 | if (!cookies.containsKey(url.host())) { 27 | cookies[url.host()] = ConcurrentHashMap() 28 | } 29 | cookies[url.host()]?.put(name, cookie) 30 | 31 | //讲cookies持久化到本地 32 | if (cookies.containsKey(url.host())) { 33 | runBlocking { 34 | cookieDataStore.updateData { 35 | it.toBuilder() 36 | .putHosts(url.host(), cookies[url.host()]?.keys?.joinToString(",")) 37 | .putCookieCache(name, CookieInfo.getDefaultInstance().fromCookie(cookie)) 38 | .build() 39 | } 40 | } 41 | } 42 | } 43 | 44 | private fun CookieInfo.fromCookie(cookie: Cookie): CookieInfo { 45 | return toBuilder().setName(cookie.name()) 46 | .setValue(cookie.value()) 47 | .setExpiresAt(cookie.expiresAt()) 48 | .setDomain(cookie.domain()) 49 | .setPath(cookie.path()) 50 | .setSecure(cookie.secure()) 51 | .setHostOnly(cookie.hostOnly()) 52 | .setHostOnly(cookie.hostOnly()) 53 | .setPersistent(cookie.persistent()) 54 | .build() 55 | } 56 | 57 | private fun CookieInfo.toCookie(): Cookie = Cookie.Builder() 58 | .name(name) 59 | .value(value) 60 | .expiresAt(expiresAt) 61 | .apply { if (hostOnly) hostOnlyDomain(domain) else domain(domain) } 62 | .path(path) 63 | .apply { if (secure) secure() } 64 | .apply { if (httpOnly) httpOnly() } 65 | .build() 66 | 67 | operator fun get(url: HttpUrl): List { 68 | val ret = ArrayList() 69 | if (cookies.containsKey(url.host())) ret.addAll(cookies[url.host()]?.values ?: emptyList()) 70 | return ret 71 | } 72 | 73 | fun removeAll(): Boolean { 74 | runBlocking { 75 | cookieDataStore.updateData { it.toBuilder().clear().build() } 76 | } 77 | cookies.clear() 78 | return true 79 | } 80 | 81 | fun remove(url: HttpUrl, cookie: Cookie): Boolean { 82 | val name = getCookieToken(cookie) 83 | return if (cookies.containsKey(url.host()) && cookies[url.host()]?.containsKey(name) == true) { 84 | cookies[url.host()]?.remove(name) 85 | runBlocking { 86 | cookieDataStore.updateData { 87 | it.toBuilder() 88 | .removeCookieCache(name) 89 | .putHosts(url.host(), cookies[url.host()]?.keys?.joinToString(",")) 90 | .build() 91 | } 92 | } 93 | true 94 | } else { 95 | false 96 | } 97 | } 98 | 99 | init { 100 | cookies = ConcurrentHashMap>() 101 | 102 | //将持久化的cookies缓存到内存中 即map cookies 103 | runBlocking { 104 | cookieDataStore.data.firstOrNull()?.run { 105 | hostsMap.forEach { entry -> 106 | val cookieNames = TextUtils.split(entry.value, ",") 107 | for (name in cookieNames) { 108 | cookieCacheMap[name]?.let { 109 | if (!cookies.containsKey(entry.key)) { 110 | cookies[entry.key] = ConcurrentHashMap() 111 | } 112 | cookies[entry.key]?.put(name, it.toCookie()) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_compose.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 15 | 18 | 21 | 24 | 27 | 30 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/repository/NetEaseMusicApi.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.repository 2 | 3 | import com.mrlin.composemany.repository.entity.* 4 | import retrofit2.Call 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | import java.util.* 8 | 9 | /********************************* 10 | * 网易云音乐API 11 | * @author mrlin 12 | * 创建于 2021年08月19日 13 | ******************************** */ 14 | interface NetEaseMusicApi { 15 | @GET("/login/refresh") 16 | fun refreshLogin(): Call 17 | 18 | /** 19 | * 手机号登录 20 | */ 21 | @GET("/login/cellphone") 22 | fun cellphoneLogin( 23 | @Query("phone") phone: String, 24 | @Query("password") password: String 25 | ): Call 26 | 27 | /** 28 | * 推荐歌单 29 | */ 30 | @GET("/recommend/resource") 31 | fun recommendResource(): Call 32 | 33 | /** 34 | * 新碟上架 35 | */ 36 | @GET("/top/album") 37 | fun topAlbums( 38 | @Query("limit") limit: Int = 5, 39 | @Query("offset") offset: Int = 0 40 | ): Call 41 | 42 | /** 43 | * MV 排行 44 | */ 45 | @GET("/top/mv") 46 | fun topMVs( 47 | @Query("limit") limit: Int = 5, 48 | @Query("offset") offset: Int = 0 49 | ): Call 50 | 51 | /** 52 | * banner数据 53 | */ 54 | @GET("/banner") 55 | fun banners(@Query("type") type: Int = BannerData.TYPE_ANDROID): Call 56 | 57 | /** 58 | * 播放列表详情 59 | */ 60 | @GET("/playlist/detail") 61 | fun playListDetail(@Query("id") id: Long): Call 62 | 63 | /** 64 | * 获取音乐 url 65 | */ 66 | @GET("/song/url") 67 | fun musicUrl(@Query("id") id: Long, @Query("br") br: Int = 128000): Call 68 | 69 | /** 70 | * 获取个人歌单 71 | */ 72 | @GET("/user/playlist") 73 | fun selfPlaylistData(@Query("uid") uid: Long): Call 74 | 75 | /** 76 | * 歌曲评论 77 | */ 78 | @GET("/comment/music") 79 | fun songCommentData( 80 | @Query("id") id: Long, 81 | //取出评论数量 , 默认为 20 82 | @Query("limit") limit: Int = 20, 83 | //偏移数量 , 用于分页 , 如 :( 评论页数 -1)*20, 其中 20 为 limit 的值 84 | @Query("offset") offset: Int 85 | ): Call 86 | 87 | /** 88 | * 新的评论接口 89 | */ 90 | @GET("/comment/new") 91 | fun commentData( 92 | @Query("id") id: Long, 93 | @Query("type") type: CommentData.Type = CommentData.Type.SONG, 94 | @Query("pageNo") pageNo: Int = 1, 95 | @Query("pageSize") pageSize: Int = 20, 96 | @Query("sortType") sortType: CommentData.SortType = CommentData.SortType.RECOMMEND, 97 | //当sortType为3时且页数不是第一页时需传入,值为上一条数据的time 98 | @Query("cursor") cursor: Long? = null, 99 | @Query("timestamp") timestamp: Long? = Date().time 100 | ): Call 101 | 102 | /** 103 | * 楼层评论 104 | */ 105 | @GET("/comment/floor") 106 | fun floorComment( 107 | //楼层评论 id 108 | @Query("parentCommentId") parentCommentId: Long, 109 | //资源 id 110 | @Query("id") id: Long, 111 | @Query("type") type: CommentData.Type = CommentData.Type.SONG, 112 | @Query("limit") limit: Int = 20, 113 | @Query("time") time: Long? = null, 114 | @Query("timestamp") timestamp: Long? = Date().time 115 | ): Call 116 | 117 | /** 118 | * 评论点赞/取消点赞 119 | */ 120 | @GET("/comment/like") 121 | fun likeComment( 122 | //资源 id, 如歌曲 id,mv id 123 | @Query("id") id: Long, 124 | //评论 id 125 | @Query("cid") cid: Long, 126 | //是否点赞 ,1 为点赞 ,0 为取消点赞 127 | @Query("t") isLike: Int, 128 | //资源类型 129 | @Query("type") type: CommentData.Type = CommentData.Type.SONG, 130 | ): Call 131 | 132 | //评论操作(发布、回复、删除) 133 | @GET("/comment") 134 | fun comment( 135 | //0 删除,1 发送, 2 回复 136 | @Query("t") operation: CommentData.Op = CommentData.Op.PUBLISH, 137 | @Query("type") type: CommentData.Type = CommentData.Type.SONG, 138 | //对应资源 id 139 | @Query("id") id: Long, 140 | @Query("content") content: String? = null, 141 | //回复的评论id (回复评论时必填) 142 | @Query("commentId") commentId: Long? = null, 143 | ): Call 144 | 145 | //喜欢音乐 146 | @GET("/like") 147 | fun likeSong(@Query("id") id: Long, @Query("like") like: Boolean = true): Call 148 | 149 | //喜欢的音乐列表 150 | @GET("likelist") 151 | fun likeList( 152 | @Query("uid") uid: Long, 153 | @Query("timestamp") timestamp: Long? = Date().time 154 | ): Call 155 | 156 | /** 157 | * 编辑歌单 158 | * @param [pid] 歌单 id 159 | * @param [tracks] 歌曲 id,可多个,用逗号隔开 160 | */ 161 | @GET("/playlist/tracks") 162 | fun editPlayList( 163 | @Query("op") op: PlayList.Op = PlayList.Op.ADD, 164 | @Query("pid") pid: Long, 165 | @Query("tracks") tracks: Long 166 | ): Call 167 | 168 | /** 169 | * 获取歌词 170 | */ 171 | @GET("/lyric") 172 | fun lyric(@Query("id") id: Long): Call 173 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/home/MusicHomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.home 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.lifecycle.ViewModel 5 | import com.mrlin.composemany.MusicSettings 6 | import com.mrlin.composemany.repository.NetEaseMusicApi 7 | import com.mrlin.composemany.repository.db.MusicDatabase 8 | import com.mrlin.composemany.repository.entity.* 9 | import com.mrlin.composemany.state.ViewState 10 | import com.mrlin.composemany.utils.busyWork 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.firstOrNull 15 | import retrofit2.await 16 | import retrofit2.awaitResponse 17 | import javax.inject.Inject 18 | 19 | /********************************* 20 | * 网易云主页数据 21 | * @author mrlin 22 | * 创建于 2021年08月19日 23 | ******************************** */ 24 | @HiltViewModel 25 | class MusicHomeViewModel @Inject constructor( 26 | private val netEaseMusicApi: NetEaseMusicApi, 27 | private val musicDb: MusicDatabase, 28 | private val musicSettings: DataStore 29 | ) : ViewModel() { 30 | private val _userState: MutableStateFlow = 31 | MutableStateFlow(MusicHomeState.Splash) 32 | private val _viewState: MutableStateFlow = MutableStateFlow(ViewState.Normal) 33 | private val _discoveryData: MutableStateFlow = 34 | MutableStateFlow(DiscoveryViewData()) 35 | //个人歌单 36 | private val _myPlayList = MutableStateFlow(ViewState.Normal) 37 | 38 | val userState: StateFlow = _userState 39 | val viewState: StateFlow = _viewState 40 | val discoveryData: StateFlow = _discoveryData 41 | val myPlayList: StateFlow = _myPlayList 42 | 43 | init { 44 | busyWork(_viewState) { 45 | val userAccountId = musicSettings.data.firstOrNull()?.userAccountId 46 | if (userAccountId != null) { 47 | //已登录用户,则直接进入已登录状态 48 | musicDb.userDao().findUser(userAccountId.toLong())?.run { 49 | _userState.emit(MusicHomeState.Login(user = this)) 50 | loadDiscoveryPage() 51 | loadMyMusicPage(user = this) 52 | return@busyWork ViewState.Normal 53 | } 54 | } 55 | val response = netEaseMusicApi.refreshLogin().awaitResponse() 56 | _userState.emit(if (response.isSuccessful) MusicHomeState.Login() else MusicHomeState.Visitor) 57 | } 58 | } 59 | 60 | /** 61 | * 登录 62 | */ 63 | fun login(phone: String, password: String) = busyWork(_viewState) { 64 | val user = netEaseMusicApi.cellphoneLogin(phone, password).awaitResponse() 65 | .takeIf { it.isSuccessful }?.body() 66 | user?.takeIf { it.isValid() }?.run { 67 | //保存已登录用户 68 | user.accountId = user.account.id 69 | musicSettings.updateData { it.toBuilder().setUserAccountId(user.accountId).build() } 70 | musicDb.userDao().insert(user = user) 71 | _userState.emit(MusicHomeState.Login(user)) 72 | loadDiscoveryPage() 73 | loadMyMusicPage(user = this) 74 | } ?: throw Throwable("登录失败") 75 | } 76 | 77 | /** 78 | * 发现页数据载入 79 | */ 80 | private fun loadDiscoveryPage() = busyWork(_viewState) { 81 | val bannerCall = netEaseMusicApi.banners() 82 | val recommendDataCall = netEaseMusicApi.recommendResource() 83 | val topAlbumDataCall = netEaseMusicApi.topAlbums() 84 | val mvListCall = netEaseMusicApi.topMVs() 85 | _discoveryData.tryEmit(DiscoveryViewData().apply { 86 | try { 87 | bannerList = bannerCall.await().banners.orEmpty() 88 | } catch (_: Throwable) { 89 | } 90 | try { 91 | recommendList = recommendDataCall.await().recommend 92 | } catch (_: Throwable) { 93 | } 94 | //新碟接口由于目前API的limit无效(无法分页返回了所有的月数据)导致数据量过大,因此暂时不使用 95 | // try { 96 | // newAlbumList = topAlbumDataCall.await().weekData.orEmpty() 97 | // } catch (_: Throwable) { 98 | // } 99 | try { 100 | topMVList = mvListCall.await().data.orEmpty() 101 | } catch (_: Throwable) { 102 | } 103 | }) 104 | } 105 | 106 | /** 107 | * “我的”页面载入 108 | */ 109 | private fun loadMyMusicPage(user: User?) = busyWork(_myPlayList) { 110 | return@busyWork MyPlayListLoaded( 111 | netEaseMusicApi.selfPlaylistData(user?.accountId ?: 0).await().playlist, user 112 | ) 113 | } 114 | } 115 | 116 | /** 117 | * 个人歌单载入完毕 118 | */ 119 | class MyPlayListLoaded(val playList: List, user: User?) : ViewState() { 120 | //我创建的歌单 121 | val selfCreates = playList.filter { it.creator?.userId == user?.accountId } 122 | 123 | //收藏的歌单 124 | val collects = playList.filterNot { it.creator?.userId == user?.accountId } 125 | } 126 | 127 | /** 128 | * 发现页数据 129 | */ 130 | class DiscoveryViewData { 131 | var bannerList: List = emptyList() 132 | var recommendList: List = emptyList() 133 | var newAlbumList: List = emptyList() 134 | var topMVList: List = emptyList() 135 | } 136 | 137 | /** 138 | * 音乐主页状态 139 | */ 140 | sealed class MusicHomeState { 141 | object Splash : MusicHomeState() 142 | object Visitor : MusicHomeState() 143 | class Login(val user: User? = null) : MusicHomeState() 144 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/home/MusicHomeFragment.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.home 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.compose.animation.Crossfade 8 | import androidx.compose.foundation.Image 9 | import androidx.compose.foundation.background 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.fillMaxSize 13 | import androidx.compose.foundation.layout.size 14 | import androidx.compose.material.CircularProgressIndicator 15 | import androidx.compose.material.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.SideEffect 18 | import androidx.compose.runtime.collectAsState 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.platform.ComposeView 24 | import androidx.compose.ui.platform.ViewCompositionStrategy 25 | import androidx.compose.ui.res.painterResource 26 | import androidx.compose.ui.text.TextStyle 27 | import androidx.compose.ui.unit.dp 28 | import androidx.fragment.app.Fragment 29 | import androidx.fragment.app.activityViewModels 30 | import androidx.fragment.app.viewModels 31 | import androidx.navigation.fragment.findNavController 32 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 33 | import com.mrlin.composemany.R 34 | import com.mrlin.composemany.pages.music.MusicScreen 35 | import com.mrlin.composemany.pages.music.PlaySongsViewModel 36 | import com.mrlin.composemany.pages.music.login.MusicLogin 37 | import com.mrlin.composemany.pages.music.widgets.PlayWidget 38 | import com.mrlin.composemany.state.ViewState 39 | import com.mrlin.composemany.ui.theme.Blue500 40 | import com.mrlin.composemany.ui.theme.ComposeManyTheme 41 | import dagger.hilt.android.AndroidEntryPoint 42 | 43 | /********************************* 44 | * 音乐主页 45 | * @author mrlin 46 | * 创建于 2021年08月23日 47 | ******************************** */ 48 | @AndroidEntryPoint 49 | class NetEaseMusicHomeFragment : Fragment() { 50 | private val viewModel by viewModels() 51 | private val playSongViewModel by activityViewModels() 52 | override fun onCreateView( 53 | inflater: LayoutInflater, 54 | container: ViewGroup?, 55 | savedInstanceState: Bundle? 56 | ): View = composeContent { 57 | val systemUiController = rememberSystemUiController() 58 | SideEffect { 59 | systemUiController.setStatusBarColor( 60 | color = Blue500 61 | ) 62 | } 63 | ComposeManyTheme { 64 | val userState by viewModel.userState.collectAsState() 65 | val playList by playSongViewModel.allSongs.collectAsState() 66 | Crossfade(targetState = userState) { 67 | when (it) { 68 | is MusicHomeState.Splash -> MusicSplash() 69 | is MusicHomeState.Visitor -> MusicLogin(viewModel) { requireActivity().finish() } 70 | is MusicHomeState.Login -> Column { 71 | MusicHome(it.user, modifier = Modifier.weight(1f)) { screen -> 72 | when (screen) { 73 | is MusicScreen -> findNavController().navigate(screen.directions) 74 | } 75 | } 76 | if (playList.isNotEmpty()) { 77 | //有播放歌单时显示播放控件 78 | PlayWidget(playSongViewModel, height = 56.dp) { 79 | findNavController().navigate(MusicScreen.PlaySong().directions) 80 | } 81 | } 82 | } 83 | } 84 | } 85 | when (val state = viewModel.viewState.collectAsState().value) { 86 | is ViewState.Busy -> Loading() 87 | is ViewState.Error -> FailureTip(reason = state.reason) 88 | } 89 | } 90 | } 91 | } 92 | 93 | @Composable 94 | private fun MusicSplash() { 95 | Box(Modifier.fillMaxSize()) { 96 | Image( 97 | painter = painterResource(id = R.drawable.music), contentDescription = null, 98 | modifier = Modifier 99 | .align(Alignment.Center) 100 | .size(128.dp) 101 | ) 102 | } 103 | } 104 | 105 | internal sealed class HomeScreen(open val route: String) { 106 | object Home : HomeScreen("home") 107 | object DailySong : HomeScreen("dailySong") 108 | object TopList : HomeScreen("topList") 109 | } 110 | 111 | internal fun Fragment.composeContent(content: @Composable () -> Unit): View = 112 | ComposeView(requireContext()).apply { 113 | setViewCompositionStrategy( 114 | ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner) 115 | ) 116 | setContent(content = { 117 | ComposeManyTheme(content = content) 118 | }) 119 | } 120 | 121 | @Composable 122 | private fun Loading() { 123 | Box( 124 | modifier = Modifier 125 | .fillMaxSize() 126 | ) { 127 | CircularProgressIndicator(Modifier.align(Alignment.Center)) 128 | } 129 | } 130 | 131 | @Composable 132 | private fun FailureTip(reason: String) { 133 | Box( 134 | modifier = Modifier 135 | .fillMaxSize() 136 | .background(color = Color.Red) 137 | ) { 138 | Text( 139 | text = reason, 140 | modifier = Modifier.align(Alignment.Center), 141 | style = TextStyle(color = Color.White) 142 | ) 143 | } 144 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.viewModels 8 | import androidx.compose.foundation.Image 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.layout.* 11 | import androidx.compose.foundation.lazy.grid.GridCells 12 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 13 | import androidx.compose.foundation.lazy.grid.items 14 | import androidx.compose.material.* 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.filled.Menu 17 | import androidx.compose.runtime.* 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.tooling.preview.Preview 23 | import androidx.compose.ui.unit.dp 24 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 25 | import com.google.accompanist.systemuicontroller.rememberSystemUiController 26 | import com.mrlin.composemany.pages.fund.FundActivity 27 | import com.mrlin.composemany.pages.mall.MallActivity 28 | import com.mrlin.composemany.pages.music.MusicSplashActivity 29 | import com.mrlin.composemany.ui.theme.Blue500 30 | import com.mrlin.composemany.ui.theme.ComposeManyTheme 31 | import dagger.hilt.android.AndroidEntryPoint 32 | import kotlinx.coroutines.flow.MutableStateFlow 33 | import kotlinx.coroutines.flow.StateFlow 34 | import kotlinx.coroutines.launch 35 | 36 | @AndroidEntryPoint 37 | class MainActivity : ComponentActivity() { 38 | private val viewModel: MainViewModel by viewModels() 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | installSplashScreen() 42 | setContent { 43 | val systemUiController = rememberSystemUiController() 44 | SideEffect { 45 | systemUiController.setStatusBarColor(color = Blue500) 46 | systemUiController.setNavigationBarColor(color = Color.Black) 47 | } 48 | LaunchedEffect(key1 = true, block = { viewModel.runTimer() }) 49 | ComposeManyTheme { 50 | // A surface container using the 'background' color from the theme 51 | Surface(color = MaterialTheme.colors.background) { 52 | Greeting("Compose", viewModel.time, viewModel.menuList(), onMenuClick = { menu -> 53 | when (menu) { 54 | is MainMenu.Fund -> startActivity(Intent(this, FundActivity::class.java)) 55 | is MainMenu.NetEaseMusic -> startActivity( 56 | Intent(this, MusicSplashActivity::class.java) 57 | ) 58 | is MainMenu.Mall -> startActivity(Intent(this, MallActivity::class.java)) 59 | } 60 | }) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | @Composable 68 | private fun Greeting( 69 | name: String, 70 | dataTimeData: StateFlow, 71 | menuList: List, 72 | onMenuClick: ((MainMenu) -> Unit)? = null, 73 | scaffoldState: ScaffoldState = rememberScaffoldState(), 74 | ) { 75 | val composableScope = rememberCoroutineScope() 76 | val dateTime: String by dataTimeData.collectAsState("") 77 | Scaffold(topBar = { 78 | TopAppBar(title = { 79 | Row( 80 | Modifier 81 | .fillMaxWidth() 82 | .padding(all = 8.dp), 83 | verticalAlignment = Alignment.CenterVertically, 84 | horizontalArrangement = Arrangement.SpaceBetween 85 | ) { 86 | Text(text = name) 87 | Text(text = dateTime, style = MaterialTheme.typography.body2) 88 | } 89 | }, navigationIcon = { 90 | IconButton(onClick = { 91 | composableScope.launch { 92 | scaffoldState.drawerState.open() 93 | } 94 | }) { 95 | Icon( 96 | imageVector = Icons.Filled.Menu, 97 | contentDescription = "" 98 | ) 99 | } 100 | }) 101 | }, drawerContent = { 102 | Surface( 103 | color = MaterialTheme.colors.primary 104 | ) { 105 | Box( 106 | modifier = Modifier 107 | .fillMaxWidth() 108 | .height(160.dp) 109 | ) { 110 | Text( 111 | text = "Compose Many", 112 | style = MaterialTheme.typography.h5, 113 | modifier = Modifier.align(Alignment.Center), 114 | ) 115 | } 116 | } 117 | }, scaffoldState = scaffoldState, backgroundColor = Color.LightGray.copy(alpha = 0.3f)) { 118 | LazyVerticalGrid(columns = GridCells.Fixed(count = 3), Modifier.padding(it)) { 119 | items(menuList) { 120 | Column( 121 | modifier = Modifier 122 | .fillMaxWidth(0.8f) 123 | .padding(8.dp) 124 | .clickable { onMenuClick?.invoke(it) }, 125 | horizontalAlignment = Alignment.CenterHorizontally 126 | ) { 127 | Image(painter = painterResource(id = it.icon), contentDescription = "") 128 | Text(text = it.name) 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | @Preview(showBackground = true) 136 | @Composable 137 | fun DefaultPreview() { 138 | ComposeManyTheme { 139 | Greeting( 140 | "Android", MutableStateFlow("2020-10-10 10:00:00"), listOf( 141 | MainMenu.Fund(), MainMenu.Mall() 142 | ) 143 | ) 144 | } 145 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/home/Mine.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.home 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.foundation.lazy.LazyColumn 7 | import androidx.compose.foundation.lazy.items 8 | import androidx.compose.material.Divider 9 | import androidx.compose.material.Icon 10 | import androidx.compose.material.MaterialTheme 11 | import androidx.compose.material.Text 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.MoreVert 14 | import androidx.compose.runtime.* 15 | import androidx.compose.ui.Alignment.Companion.CenterVertically 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.res.painterResource 18 | import androidx.compose.ui.tooling.preview.Preview 19 | import androidx.compose.ui.unit.dp 20 | import coil.compose.rememberImagePainter 21 | import com.google.accompanist.placeholder.material.placeholder 22 | import com.mrlin.composemany.R 23 | import com.mrlin.composemany.pages.music.MusicScreen 24 | import com.mrlin.composemany.repository.entity.* 25 | import com.mrlin.composemany.state.ViewState 26 | 27 | /********************************* 28 | * 【我的】页面 29 | * @author mrlin 30 | * 创建于 2021年09月03日 31 | ******************************** */ 32 | @Composable 33 | fun Mine(myPlayList: ViewState, onToScreen: ((Any) -> Unit)? = null) { 34 | val (loaded, playList) = if (myPlayList !is MyPlayListLoaded) { 35 | //未载入状态 36 | false to MyPlayListLoaded(emptyList(), null) 37 | } else { 38 | true to myPlayList 39 | } 40 | val topMenus = listOf( 41 | "本地音乐" to R.drawable.icon_music, 42 | "最近播放" to R.drawable.icon_late_play, 43 | "下载管理" to R.drawable.icon_download_black, 44 | "我的电台" to R.drawable.icon_broadcast, 45 | "我的收藏" to R.drawable.icon_collect, 46 | ) 47 | var selfCreatesExpand by remember { mutableStateOf(true) } 48 | var collectsExpand by remember { mutableStateOf(false) } 49 | LazyColumn(modifier = Modifier.fillMaxSize()) { 50 | items(topMenus.size) { index -> 51 | val menu = topMenus[index] 52 | Row(Modifier.height(56.dp), verticalAlignment = CenterVertically) { 53 | Image( 54 | painter = painterResource(id = menu.second), 55 | contentDescription = null, 56 | modifier = Modifier 57 | .weight(1.0f) 58 | .padding(10.dp) 59 | .placeholder(!loaded) 60 | ) 61 | Text(text = menu.first, modifier = Modifier.weight(5.0f)) 62 | } 63 | Divider() 64 | } 65 | item { 66 | PlaylistTitle(title = "创建的歌单", count = playList.selfCreates.size, selfCreatesExpand) { 67 | selfCreatesExpand = !selfCreatesExpand 68 | } 69 | } 70 | if (selfCreatesExpand) { 71 | items(playList.selfCreates) { 72 | PlaylistItem(playList = it) { onToScreen?.toPlayListScreen(it) } 73 | } 74 | } 75 | item { 76 | PlaylistTitle(title = "收藏的歌单", count = playList.collects.size, collectsExpand) { 77 | collectsExpand = !collectsExpand 78 | } 79 | } 80 | if (collectsExpand) { 81 | items(playList.collects) { 82 | PlaylistItem(playList = it) { onToScreen?.toPlayListScreen(it) } 83 | } 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * 歌单标题 90 | */ 91 | @Composable 92 | private fun PlaylistTitle(title: String, count: Int, expanded: Boolean, onToggleExpand: () -> Unit) { 93 | Row( 94 | verticalAlignment = CenterVertically, modifier = Modifier 95 | .height(48.dp) 96 | .clickable { onToggleExpand() } 97 | .padding(8.dp) 98 | ) { 99 | Image( 100 | painter = painterResource(id = if (expanded) R.drawable.icon_up else R.drawable.icon_down), 101 | contentDescription = null, 102 | Modifier.size(20.dp) 103 | ) 104 | Spacer(modifier = Modifier.width(8.dp)) 105 | Text(text = "$title (${count})") 106 | Spacer(modifier = Modifier.weight(1.0f)) 107 | Icon(imageVector = Icons.Default.MoreVert, contentDescription = null, modifier = Modifier.size(20.dp)) 108 | } 109 | } 110 | 111 | /** 112 | * 歌单项 113 | */ 114 | @Composable 115 | private fun PlaylistItem(playList: PlayList, onClick: () -> Unit) { 116 | Row( 117 | modifier = Modifier 118 | .height(72.dp) 119 | .fillMaxWidth() 120 | .padding(8.dp) 121 | .clickable(onClick = onClick), 122 | verticalAlignment = CenterVertically 123 | ) { 124 | Image(painter = rememberImagePainter(data = playList.coverImgUrl.limitSize(80)), contentDescription = null) 125 | Spacer(modifier = Modifier.width(8.dp)) 126 | Column { 127 | Text(text = playList.name, maxLines = 1) 128 | Text(text = "${playList.trackCount}首", style = MaterialTheme.typography.subtitle2) 129 | } 130 | } 131 | } 132 | 133 | /** 134 | * 跳转到歌单详情页 135 | */ 136 | private fun ((Any) -> Unit).toPlayListScreen(playList: PlayList) { 137 | this( 138 | MusicScreen.PlayList( 139 | Recommend( 140 | id = playList.id, 141 | name = playList.name, 142 | playcount = playList.playCount, 143 | picUrl = playList.coverImgUrl 144 | ) 145 | ) 146 | ) 147 | } 148 | 149 | @Preview(showBackground = true) 150 | @Composable 151 | fun MinePreview() { 152 | Mine( 153 | myPlayList = MyPlayListLoaded( 154 | listOf( 155 | PlayList( 156 | listOf( 157 | Track("第一首", 1, 21, listOf(Ar(1, "歌手1")), Al(1, "Al1")), 158 | Track("第二首", 2, 22, listOf(Ar(2, "歌手2")), Al(2, "Al2")), 159 | Track("第三首", 3, 23, listOf(Ar(3, "歌手3")), Al(3, "Al3")) 160 | ) 161 | ) 162 | ), User( 163 | 0, 0, Account(1, "用户", 0, 0, 0, 0, "", 0, 0, 0, 0, 0, 0, false), 164 | Profile("", "", "") 165 | ) 166 | ) 167 | ) 168 | } -------------------------------------------------------------------------------- /app/schemas/com.mrlin.composemany.repository.db.MusicDatabase/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "formatVersion": 1, 3 | "database": { 4 | "version": 1, 5 | "identityHash": "61d724853a055db11f7ce43549162224", 6 | "entities": [ 7 | { 8 | "tableName": "User", 9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`loginType` INTEGER NOT NULL, `code` INTEGER NOT NULL, `token` TEXT, `cookie` TEXT, `accountId` INTEGER NOT NULL, `ua_id` INTEGER NOT NULL, `ua_user_name` TEXT NOT NULL, `ua_type` INTEGER NOT NULL, `ua_status` INTEGER NOT NULL, `ua_whitelist_authority` INTEGER NOT NULL, `ua_create_time` INTEGER NOT NULL, `ua_salt` TEXT NOT NULL, `ua_token_version` INTEGER NOT NULL, `ua_ban` INTEGER NOT NULL, `ua_baoyue_version` INTEGER NOT NULL, `ua_donate_version` INTEGER NOT NULL, `ua_vip_type` INTEGER NOT NULL, `ua_viptype_version` INTEGER NOT NULL, `ua_anonimous_user` INTEGER NOT NULL, `up_avatarImgIdStr` TEXT NOT NULL, `up_nickname` TEXT NOT NULL, `up_avatarUrl` TEXT NOT NULL, `up_signature` TEXT, PRIMARY KEY(`accountId`))", 10 | "fields": [ 11 | { 12 | "fieldPath": "loginType", 13 | "columnName": "loginType", 14 | "affinity": "INTEGER", 15 | "notNull": true 16 | }, 17 | { 18 | "fieldPath": "code", 19 | "columnName": "code", 20 | "affinity": "INTEGER", 21 | "notNull": true 22 | }, 23 | { 24 | "fieldPath": "token", 25 | "columnName": "token", 26 | "affinity": "TEXT", 27 | "notNull": false 28 | }, 29 | { 30 | "fieldPath": "cookie", 31 | "columnName": "cookie", 32 | "affinity": "TEXT", 33 | "notNull": false 34 | }, 35 | { 36 | "fieldPath": "accountId", 37 | "columnName": "accountId", 38 | "affinity": "INTEGER", 39 | "notNull": true 40 | }, 41 | { 42 | "fieldPath": "account.id", 43 | "columnName": "ua_id", 44 | "affinity": "INTEGER", 45 | "notNull": true 46 | }, 47 | { 48 | "fieldPath": "account.userName", 49 | "columnName": "ua_user_name", 50 | "affinity": "TEXT", 51 | "notNull": true 52 | }, 53 | { 54 | "fieldPath": "account.type", 55 | "columnName": "ua_type", 56 | "affinity": "INTEGER", 57 | "notNull": true 58 | }, 59 | { 60 | "fieldPath": "account.status", 61 | "columnName": "ua_status", 62 | "affinity": "INTEGER", 63 | "notNull": true 64 | }, 65 | { 66 | "fieldPath": "account.whitelistAuthority", 67 | "columnName": "ua_whitelist_authority", 68 | "affinity": "INTEGER", 69 | "notNull": true 70 | }, 71 | { 72 | "fieldPath": "account.createTime", 73 | "columnName": "ua_create_time", 74 | "affinity": "INTEGER", 75 | "notNull": true 76 | }, 77 | { 78 | "fieldPath": "account.salt", 79 | "columnName": "ua_salt", 80 | "affinity": "TEXT", 81 | "notNull": true 82 | }, 83 | { 84 | "fieldPath": "account.tokenVersion", 85 | "columnName": "ua_token_version", 86 | "affinity": "INTEGER", 87 | "notNull": true 88 | }, 89 | { 90 | "fieldPath": "account.ban", 91 | "columnName": "ua_ban", 92 | "affinity": "INTEGER", 93 | "notNull": true 94 | }, 95 | { 96 | "fieldPath": "account.baoyueVersion", 97 | "columnName": "ua_baoyue_version", 98 | "affinity": "INTEGER", 99 | "notNull": true 100 | }, 101 | { 102 | "fieldPath": "account.donateVersion", 103 | "columnName": "ua_donate_version", 104 | "affinity": "INTEGER", 105 | "notNull": true 106 | }, 107 | { 108 | "fieldPath": "account.vipType", 109 | "columnName": "ua_vip_type", 110 | "affinity": "INTEGER", 111 | "notNull": true 112 | }, 113 | { 114 | "fieldPath": "account.viptypeVersion", 115 | "columnName": "ua_viptype_version", 116 | "affinity": "INTEGER", 117 | "notNull": true 118 | }, 119 | { 120 | "fieldPath": "account.anonimousUser", 121 | "columnName": "ua_anonimous_user", 122 | "affinity": "INTEGER", 123 | "notNull": true 124 | }, 125 | { 126 | "fieldPath": "profile.avatarImgIdStr", 127 | "columnName": "up_avatarImgIdStr", 128 | "affinity": "TEXT", 129 | "notNull": true 130 | }, 131 | { 132 | "fieldPath": "profile.nickname", 133 | "columnName": "up_nickname", 134 | "affinity": "TEXT", 135 | "notNull": true 136 | }, 137 | { 138 | "fieldPath": "profile.avatarUrl", 139 | "columnName": "up_avatarUrl", 140 | "affinity": "TEXT", 141 | "notNull": true 142 | }, 143 | { 144 | "fieldPath": "profile.signature", 145 | "columnName": "up_signature", 146 | "affinity": "TEXT", 147 | "notNull": false 148 | } 149 | ], 150 | "primaryKey": { 151 | "columnNames": [ 152 | "accountId" 153 | ], 154 | "autoGenerate": false 155 | }, 156 | "indices": [ 157 | { 158 | "name": "index_User_ua_id", 159 | "unique": true, 160 | "columnNames": [ 161 | "ua_id" 162 | ], 163 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_User_ua_id` ON `${TABLE_NAME}` (`ua_id`)" 164 | } 165 | ], 166 | "foreignKeys": [] 167 | } 168 | ], 169 | "views": [], 170 | "setupQueries": [ 171 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", 172 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61d724853a055db11f7ce43549162224')" 173 | ] 174 | } 175 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/PlaySongsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music 2 | 3 | import android.media.MediaPlayer 4 | import androidx.datastore.core.DataStore 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import com.mrlin.composemany.MusicSettings 8 | import com.mrlin.composemany.repository.NetEaseMusicApi 9 | import com.mrlin.composemany.repository.entity.Song 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import kotlinx.coroutines.* 12 | import kotlinx.coroutines.flow.* 13 | import retrofit2.await 14 | import java.io.IOException 15 | import javax.inject.Inject 16 | 17 | @HiltViewModel 18 | class PlaySongsViewModel @Inject constructor( 19 | private val musicApi: NetEaseMusicApi, 20 | private val musicSettingsStore: DataStore 21 | ) : ViewModel() { 22 | private val _songs = MutableStateFlow(emptyList()) 23 | private var _curIndex = MutableStateFlow(0) 24 | private var _mediaPlayer: MediaPlayer? = null 25 | private val _curProgress = MutableStateFlow(0f) 26 | private val _isPlaying = MutableStateFlow(false) 27 | private var tryingSeek = false 28 | private val _likeList = MutableStateFlow(mutableListOf()) 29 | private val _lyric = MutableStateFlow(listOf>()) 30 | private val _curSong = _curIndex.map { allSongs.value.getOrNull(it) }.stateIn( 31 | viewModelScope, SharingStarted.WhileSubscribed(), null 32 | ) 33 | 34 | val allSongs: StateFlow> = _songs 35 | val curSong: StateFlow = _curSong 36 | val curProgress: StateFlow = _curProgress 37 | val isPlaying: StateFlow = _isPlaying 38 | val likeList: StateFlow> = _likeList 39 | val lyric: StateFlow>> = _lyric 40 | 41 | init { 42 | _mediaPlayer = MediaPlayer() 43 | viewModelScope.launch { 44 | //载入喜欢的音乐id列表 45 | val uid = musicSettingsStore.data.map { it.userAccountId.toLong() }.firstOrNull() 46 | try { 47 | val likeList = musicApi.likeList(uid ?: 0L).await() 48 | _likeList.value = ArrayList(likeList.ids) 49 | } catch (t: Throwable) { 50 | t.printStackTrace() 51 | } 52 | } 53 | } 54 | 55 | private fun play() = viewModelScope.launch { 56 | val songId = _songs.value[_curIndex.value].id 57 | launch { 58 | //载入歌词 59 | try { 60 | val lyricData = musicApi.lyric(songId).await() 61 | _lyric.value = lyricData.lrc.parseToList() 62 | } catch (t: Throwable) { 63 | t.printStackTrace() 64 | } 65 | } 66 | try { 67 | val url = musicApi.musicUrl(songId).await().data.firstOrNull()?.url 68 | ?: "https://music.163.com/song/media/outer/url?id=${songId}.mp3" 69 | playMusic(url = url) 70 | _isPlaying.value = _mediaPlayer?.isPlaying ?: false 71 | queryProgress() 72 | } catch (ioe: IOException) { 73 | nextPlay() 74 | } catch (t: Throwable) { 75 | t.printStackTrace() 76 | } 77 | } 78 | 79 | private fun queryProgress() = viewModelScope.launch { 80 | while (_mediaPlayer?.isPlaying == true) { 81 | if (!tryingSeek) { 82 | _curProgress.emit( 83 | (_mediaPlayer?.currentPosition?.toFloat() 84 | ?: 0f) / (_mediaPlayer?.duration?.toFloat() 85 | ?: 0f) 86 | ) 87 | } 88 | delay(1000) 89 | } 90 | _isPlaying.tryEmit(false) 91 | } 92 | 93 | fun playSongs(songs: List, index: Int? = null) { 94 | _songs.tryEmit(songs) 95 | index?.let { _curIndex.tryEmit(it) } 96 | play() 97 | } 98 | 99 | /** 100 | * 暂停、恢复 101 | */ 102 | fun togglePlay() { 103 | if (_mediaPlayer?.isPlaying != true) { 104 | _mediaPlayer?.start() 105 | queryProgress() 106 | } else { 107 | _mediaPlayer?.pause() 108 | } 109 | _isPlaying.tryEmit(_mediaPlayer?.isPlaying ?: false) 110 | } 111 | 112 | /** 113 | * 准备跳转到指定进度 114 | */ 115 | fun trySeek(progress: Float) { 116 | tryingSeek = true 117 | _curProgress.tryEmit(progress) 118 | } 119 | 120 | /** 121 | * 跳转到固定进度 122 | */ 123 | fun seekPlay() { 124 | val progress = _curProgress.value 125 | _mediaPlayer?.seekTo((progress * (_mediaPlayer?.duration?.toFloat() ?: 0f)).toInt()) 126 | tryingSeek = false 127 | } 128 | 129 | /** 130 | * 上一首 131 | */ 132 | fun prevPlay() { 133 | _curIndex.value = _curIndex.updateAndGet { (it - 1).coerceAtLeast(0) } 134 | play() 135 | } 136 | 137 | /** 138 | * 下一首 139 | */ 140 | fun nextPlay() { 141 | _curIndex.value = _curIndex.updateAndGet { (it + 1).coerceAtMost(_songs.value.size - 1) } 142 | play() 143 | } 144 | 145 | /** 146 | * 喜欢/不喜欢歌曲 147 | */ 148 | fun toggleLike() = viewModelScope.launch { 149 | try { 150 | val song = _curSong.value ?: return@launch 151 | val like = _likeList.value.contains(song.id) 152 | val response = musicApi.likeSong(song.id, !like).await() 153 | if (response.code == 200) { 154 | _likeList.value = _likeList.value.apply { 155 | if (like) { 156 | remove(song.id) 157 | } else { 158 | add(song.id) 159 | } 160 | } 161 | } 162 | } catch (t: Throwable) { 163 | t.printStackTrace() 164 | } 165 | } 166 | 167 | @Suppress("BlockingMethodInNonBlockingContext") 168 | private suspend fun playMusic(url: String) = withContext(Dispatchers.IO) { 169 | _mediaPlayer?.setOnCompletionListener(null) 170 | _mediaPlayer?.stop() 171 | _mediaPlayer?.seekTo(0) 172 | _mediaPlayer?.setDataSource(url) 173 | _mediaPlayer?.prepare() 174 | _mediaPlayer?.start() 175 | _mediaPlayer?.setOnCompletionListener { nextPlay() } 176 | } 177 | 178 | override fun onCleared() { 179 | _mediaPlayer?.release() 180 | _mediaPlayer = null 181 | super.onCleared() 182 | } 183 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/home/Discovery.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.home 2 | 3 | import androidx.compose.foundation.* 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.LazyRow 6 | import androidx.compose.foundation.lazy.grid.GridCells 7 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 8 | import androidx.compose.foundation.lazy.grid.items 9 | import androidx.compose.foundation.lazy.items 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.MaterialTheme 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.Brush 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.res.painterResource 19 | import androidx.compose.ui.unit.dp 20 | import com.google.accompanist.placeholder.PlaceholderHighlight 21 | import com.google.accompanist.placeholder.material.placeholder 22 | import com.google.accompanist.placeholder.material.shimmer 23 | import com.mrlin.composemany.R 24 | import com.mrlin.composemany.pages.music.MusicScreen 25 | import com.mrlin.composemany.pages.music.widgets.CustomBanner 26 | import com.mrlin.composemany.pages.music.widgets.PlayListWidget 27 | import com.mrlin.composemany.repository.entity.Album 28 | import com.mrlin.composemany.repository.entity.MVData 29 | import com.mrlin.composemany.repository.entity.Recommend 30 | 31 | /** 32 | * 【发现】页 33 | */ 34 | @Composable 35 | internal fun Discovery( 36 | discoveryViewData: DiscoveryViewData, 37 | onToScreen: ((Any) -> Unit)? = null 38 | ) { 39 | Column { 40 | CustomBanner(urls = discoveryViewData.bannerList.map { it.pic.orEmpty() }, height = 140) { 41 | 42 | } 43 | CategoryList { menuName -> 44 | when (menuName) { 45 | "每日推荐" -> onToScreen?.invoke(HomeScreen.DailySong) 46 | "排行榜" -> onToScreen?.invoke(HomeScreen.TopList) 47 | } 48 | } 49 | Column(Modifier.verticalScroll(rememberScrollState())) { 50 | Text(text = "推荐歌单", modifier = Modifier.padding(10.dp)) 51 | Box(modifier = Modifier.height(140.dp)) { 52 | RecommendPlayList(discoveryViewData.recommendList) { 53 | onToScreen?.invoke(MusicScreen.PlayList(it)) 54 | } 55 | } 56 | discoveryViewData.newAlbumList.takeIf { it.isNotEmpty() }?.let { 57 | Text(text = "新碟上架", modifier = Modifier.padding(10.dp)) 58 | Box(modifier = Modifier.height(140.dp)) { NewAlbumList(it) } 59 | } 60 | discoveryViewData.topMVList.takeIf { it.isNotEmpty() }?.let { 61 | Text(text = "MV 排行", modifier = Modifier.padding(10.dp)) 62 | Box(modifier = Modifier.height(140.dp)) { TopMvList(it) } 63 | } 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * 分类列表 70 | */ 71 | @Composable 72 | private fun CategoryList(onClick: (String) -> Unit) { 73 | val menus = mapOf( 74 | "每日推荐" to R.drawable.icon_daily, 75 | "歌单" to R.drawable.icon_playlist, 76 | "排行榜" to R.drawable.icon_rank, 77 | "电台" to R.drawable.icon_radio, 78 | "直播" to R.drawable.icon_look, 79 | ) 80 | LazyVerticalGrid( 81 | modifier = Modifier.wrapContentHeight(), 82 | columns = GridCells.Fixed(5), 83 | content = { 84 | items(menus.entries.toList()) { 85 | Column( 86 | Modifier 87 | .aspectRatio(1 / 1.3f) 88 | .padding(8.dp) 89 | .clickable { onClick(it.key) }, 90 | horizontalAlignment = Alignment.CenterHorizontally 91 | ) { 92 | Box( 93 | modifier = Modifier 94 | .background( 95 | brush = Brush.radialGradient( 96 | listOf(Color(0xFFFF8174), Color.Red), 97 | radius = 1.0f 98 | ), 99 | shape = RoundedCornerShape(50) 100 | ) 101 | .border( 102 | width = 0.5.dp, 103 | color = Color.LightGray, 104 | shape = RoundedCornerShape(50) 105 | ), 106 | contentAlignment = Alignment.Center 107 | ) { 108 | Image(painter = painterResource(id = it.value), contentDescription = null) 109 | } 110 | Spacer(modifier = Modifier.height(10.dp)) 111 | Text(text = it.key, style = MaterialTheme.typography.caption) 112 | } 113 | } 114 | }) 115 | } 116 | 117 | /** 118 | * 推荐歌单 119 | */ 120 | @Composable 121 | private fun RecommendPlayList(recommendList: List, onClick: (Recommend) -> Unit) { 122 | LazyRow( 123 | horizontalArrangement = Arrangement.spacedBy(4.dp), 124 | modifier = Modifier 125 | .fillMaxSize() 126 | .padding(10.dp) 127 | .placeholder( 128 | recommendList.isEmpty(), 129 | highlight = PlaceholderHighlight.shimmer() 130 | ) 131 | ) { 132 | items(recommendList) { 133 | PlayListWidget( 134 | text = it.name, picUrl = it.picUrl, playCount = it.playcount, maxLines = 2 135 | ) { 136 | onClick(it) 137 | } 138 | } 139 | } 140 | } 141 | 142 | /** 143 | * 新碟上架 144 | */ 145 | @Composable 146 | private fun NewAlbumList(albumList: List) { 147 | LazyRow( 148 | horizontalArrangement = Arrangement.spacedBy(4.dp), 149 | contentPadding = PaddingValues(10.dp) 150 | ) { 151 | items(albumList) { 152 | PlayListWidget( 153 | text = it.name.orEmpty(), picUrl = it.picUrl, subText = it.artist?.name.orEmpty(), 154 | maxLines = 2 155 | ) { 156 | 157 | } 158 | } 159 | } 160 | } 161 | 162 | /** 163 | * MV 排行 164 | */ 165 | @Composable 166 | private fun TopMvList(mvList: List) { 167 | LazyRow( 168 | horizontalArrangement = Arrangement.spacedBy(4.dp), 169 | contentPadding = PaddingValues(10.dp) 170 | ) { 171 | items(mvList) { 172 | PlayListWidget( 173 | text = it.name.orEmpty(), picUrl = it.cover.orEmpty(), 174 | subText = it.artistName.orEmpty(), maxLines = 2 175 | ) { 176 | 177 | } 178 | } 179 | } 180 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/home/MusicHome.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.home 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.KeyboardArrowRight 9 | import androidx.compose.material.icons.filled.Menu 10 | import androidx.compose.material.icons.filled.Search 11 | import androidx.compose.material.icons.outlined.Check 12 | import androidx.compose.material.icons.outlined.Info 13 | import androidx.compose.material.icons.outlined.MoreVert 14 | import androidx.compose.material.icons.outlined.Settings 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.collectAsState 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.rememberCoroutineScope 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.clip 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.vector.ImageVector 24 | import androidx.compose.ui.tooling.preview.Preview 25 | import androidx.compose.ui.unit.dp 26 | import androidx.hilt.navigation.compose.hiltViewModel 27 | import com.google.accompanist.pager.ExperimentalPagerApi 28 | import com.google.accompanist.pager.HorizontalPager 29 | import com.google.accompanist.pager.rememberPagerState 30 | import com.mrlin.composemany.pages.music.widgets.CircleAvatar 31 | import com.mrlin.composemany.repository.entity.User 32 | import com.mrlin.composemany.ui.theme.ComposeManyTheme 33 | import com.mrlin.composemany.ui.theme.LightGray 34 | import kotlinx.coroutines.launch 35 | 36 | /********************************* 37 | * 音乐主页 38 | * @author mrlin 39 | * 创建于 2021年08月20日 40 | ******************************** */ 41 | @Composable 42 | fun MusicHome( 43 | user: User?, 44 | modifier: Modifier = Modifier, 45 | musicHomeViewModel: MusicHomeViewModel = hiltViewModel(), 46 | onToScreen: ((Any) -> Unit)? = null 47 | ) { 48 | val scaffoldState = rememberScaffoldState() 49 | val coroutineScope = rememberCoroutineScope() 50 | Scaffold( 51 | drawerContent = { Drawer(user = user) }, 52 | scaffoldState = scaffoldState, 53 | modifier = modifier 54 | ) { padding -> 55 | Home(padding, musicHomeViewModel, onDrawerClick = { 56 | coroutineScope.launch { 57 | scaffoldState.drawerState.open() 58 | } 59 | }, onToScreen = onToScreen) 60 | } 61 | } 62 | 63 | @Composable 64 | private fun Drawer(user: User?) { 65 | Column( 66 | Modifier 67 | .background(LightGray) 68 | .padding(16.dp) 69 | .fillMaxSize() 70 | ) { 71 | Row(verticalAlignment = Alignment.CenterVertically) { 72 | CircleAvatar(url = user?.profile?.avatarUrl) 73 | Spacer(modifier = Modifier.width(8.dp)) 74 | Text(text = "${user?.profile?.nickname.orEmpty()} >") 75 | } 76 | Spacer(modifier = Modifier.height(16.dp)) 77 | Column( 78 | Modifier 79 | .clip(RoundedCornerShape(10.dp)) 80 | .background(Color.White) 81 | ) { 82 | DrawerItem(title = "设置", Icons.Outlined.Settings) 83 | Divider() 84 | DrawerItem(title = "夜间模式", Icons.Outlined.Check) 85 | } 86 | Spacer(modifier = Modifier.height(16.dp)) 87 | Column( 88 | Modifier 89 | .clip(RoundedCornerShape(10.dp)) 90 | .background(Color.White) 91 | ) { 92 | DrawerItem(title = "我的订单", Icons.Outlined.MoreVert) 93 | Divider() 94 | DrawerItem(title = "关于", Icons.Outlined.Info) 95 | } 96 | Spacer(modifier = Modifier.height(16.dp)) 97 | Column( 98 | Modifier 99 | .fillMaxWidth() 100 | .clip(RoundedCornerShape(10.dp)) 101 | .background(Color.White) 102 | .padding(16.dp) 103 | ) { 104 | Text(text = "退出登录/关闭", color = Color.Red) 105 | } 106 | } 107 | } 108 | 109 | @Composable 110 | private fun DrawerItem(title: String, imageVector: ImageVector? = null) { 111 | Row(modifier = Modifier.padding(16.dp)) { 112 | imageVector?.let { 113 | Icon(imageVector = it, contentDescription = null) 114 | } 115 | Text(text = title, modifier = Modifier.padding(start = 16.dp)) 116 | Spacer(modifier = Modifier.weight(1f)) 117 | Icon( 118 | imageVector = Icons.Default.KeyboardArrowRight, 119 | contentDescription = null, 120 | tint = Color.LightGray 121 | ) 122 | } 123 | } 124 | 125 | @Composable 126 | @OptIn(ExperimentalPagerApi::class) 127 | private fun Home( 128 | paddingValues: PaddingValues, 129 | vm: MusicHomeViewModel? = null, 130 | onDrawerClick: (() -> Unit)? = null, 131 | onToScreen: ((Any) -> Unit)? = null 132 | ) { 133 | Column( 134 | modifier = Modifier 135 | .padding(paddingValues) 136 | .fillMaxSize() 137 | .background(color = LightGray) 138 | ) { 139 | val discoveryViewData by vm?.discoveryData?.collectAsState() ?: return 140 | val myPlayList by vm?.myPlayList?.collectAsState() ?: return 141 | Row( 142 | Modifier 143 | .height(56.dp) 144 | .fillMaxWidth() 145 | .padding(10.dp), 146 | verticalAlignment = Alignment.CenterVertically, 147 | ) { 148 | IconButton(onClick = { onDrawerClick?.invoke() }, modifier = Modifier.size(36.dp)) { 149 | Icon(Icons.Default.Menu, contentDescription = null, tint = Color.Gray) 150 | } 151 | Spacer(modifier = Modifier.width(8.dp)) 152 | Row( 153 | modifier = Modifier 154 | .weight(1f) 155 | .clip(RoundedCornerShape(50)) 156 | .background(Color.White) 157 | .padding(8.dp), 158 | verticalAlignment = Alignment.CenterVertically 159 | ) { 160 | Icon( 161 | imageVector = Icons.Default.Search, 162 | contentDescription = null, 163 | tint = Color.LightGray 164 | ) 165 | Spacer(modifier = Modifier.width(8.dp)) 166 | Text(text = "大家都在搜 陈奕迅", color = Color.LightGray) 167 | } 168 | Spacer(modifier = Modifier.width(8.dp)) 169 | } 170 | val pages = listOf(Page.Recovery, Page.Mine, Page.Active) 171 | val pagerState = rememberPagerState() 172 | val pagerScope = rememberCoroutineScope() 173 | TabRow(selectedTabIndex = pagerState.currentPage, backgroundColor = Color.Transparent) { 174 | pages.forEachIndexed { index, page -> 175 | Tab(tab = page.label, selected = index == pagerState.currentPage) { 176 | pagerScope.launch { pagerState.scrollToPage(index) } 177 | } 178 | } 179 | } 180 | HorizontalPager( 181 | state = pagerState, verticalAlignment = Alignment.Top, count = 3 182 | ) { page -> 183 | when (pages[page]) { 184 | Page.Recovery -> Discovery(discoveryViewData, onToScreen = onToScreen) 185 | Page.Mine -> Mine(myPlayList, onToScreen = onToScreen) 186 | Page.Active -> NewAction() 187 | } 188 | } 189 | } 190 | } 191 | 192 | private sealed class Page( 193 | val label: String 194 | ) { 195 | object Recovery : Page("发现") 196 | object Mine : Page("我的") 197 | object Active : Page("动态") 198 | } 199 | 200 | @Composable 201 | private fun Tab(tab: String, selected: Boolean, onClick: () -> Unit) { 202 | Tab( 203 | selected = selected, onClick = onClick, modifier = Modifier.height(48.dp) 204 | ) { 205 | Text(text = tab) 206 | } 207 | } 208 | 209 | @Composable 210 | private fun NewAction() { 211 | Box(Modifier.fillMaxSize()) { 212 | Text(text = "动态", modifier = Modifier.align(Alignment.Center)) 213 | } 214 | } 215 | 216 | @Preview 217 | @Composable 218 | fun MusicHomePagePreview() { 219 | ComposeManyTheme { 220 | Home(PaddingValues(0.dp)) 221 | } 222 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'kotlin-kapt' 5 | id 'dagger.hilt.android.plugin' 6 | //protobuf生成 7 | id "com.google.protobuf" version "0.8.12" 8 | //自动生成parcelable 9 | id 'kotlin-parcelize' 10 | //navigation 安全类型数据 11 | id "androidx.navigation.safeargs.kotlin" 12 | // id 'com.bytedance.android.aabResGuard' 13 | } 14 | 15 | android { 16 | compileSdkVersion 33 17 | 18 | defaultConfig { 19 | applicationId "com.mrlin.composemany" 20 | minSdkVersion 21 21 | targetSdkVersion 33 22 | versionCode 1 23 | versionName "1.0.0" 24 | 25 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 26 | vectorDrawables { 27 | useSupportLibrary true 28 | } 29 | 30 | javaCompileOptions { 31 | annotationProcessorOptions { 32 | arguments += [ 33 | "room.schemaLocation" : "$projectDir/schemas".toString(), 34 | "room.incremental" : "true", 35 | "room.expandProjection": "true"] 36 | } 37 | } 38 | } 39 | 40 | buildTypes { 41 | release { 42 | minifyEnabled true 43 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 44 | signingConfig signingConfigs.debug 45 | } 46 | debug { 47 | signingConfig signingConfigs.debug 48 | } 49 | } 50 | compileOptions { 51 | sourceCompatibility JavaVersion.VERSION_1_8 52 | targetCompatibility JavaVersion.VERSION_1_8 53 | } 54 | kotlinOptions { 55 | jvmTarget = '1.8' 56 | } 57 | buildFeatures { 58 | compose true 59 | } 60 | composeOptions { 61 | kotlinCompilerExtensionVersion compose_compiler_version 62 | } 63 | packagingOptions { 64 | resources { 65 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 66 | } 67 | } 68 | signingConfigs { 69 | debug { 70 | storeFile file('../ks/composemany.jks') 71 | storePassword 'composemany' 72 | keyAlias 'many' 73 | keyPassword 'composemany' 74 | } 75 | } 76 | } 77 | 78 | dependencies { 79 | 80 | def composeBom = platform('androidx.compose:compose-bom:2022.10.00') 81 | implementation composeBom 82 | androidTestImplementation composeBom 83 | implementation 'androidx.core:core-ktx:1.8.0' 84 | implementation 'androidx.appcompat:appcompat:1.4.2' 85 | implementation 'com.google.android.material:material:1.6.1' 86 | implementation "androidx.compose.ui:ui" 87 | implementation "androidx.compose.material:material" 88 | implementation "androidx.compose.ui:ui-tooling" 89 | //compose livedata扩展 90 | implementation "androidx.compose.runtime:runtime-livedata" 91 | //compose viewModel扩展 92 | implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" 93 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' 94 | implementation 'androidx.activity:activity-compose:1.5.1' 95 | //fragment 96 | implementation "androidx.fragment:fragment-ktx:$fragment_version" 97 | //navigation 98 | implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" 99 | implementation "androidx.navigation:navigation-ui-ktx:$nav_version" 100 | implementation "androidx.navigation:navigation-compose:$nav_version" 101 | implementation "androidx.hilt:hilt-navigation-compose:1.0.0" 102 | //hilt 103 | implementation "com.google.dagger:hilt-android:$hilt_android_version" 104 | kapt "com.google.dagger:hilt-compiler:$hilt_android_version" 105 | //ROOM 106 | def room_version = "2.4.3" 107 | implementation "androidx.room:room-runtime:$room_version" 108 | implementation "androidx.room:room-ktx:$room_version" 109 | kapt "androidx.room:room-compiler:$room_version" 110 | //datastore 111 | implementation "androidx.datastore:datastore:1.0.0" 112 | implementation "com.google.protobuf:protobuf-javalite:3.18.0" 113 | //splashscreen 114 | implementation "androidx.core:core-splashscreen:1.0.0-beta02" 115 | //paging 116 | implementation "androidx.paging:paging-compose:1.0.0-alpha14" 117 | //coil 118 | implementation('io.coil-kt:coil-compose:1.4.0') 119 | implementation "androidx.compose.ui:ui-tooling-preview" 120 | testImplementation 'junit:junit:4.13.2' 121 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 122 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 123 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" 124 | 125 | def accompanist_version = "0.27.0" 126 | //accompanist - pager 127 | implementation "com.google.accompanist:accompanist-pager:$accompanist_version" 128 | implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version" 129 | implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" 130 | implementation "com.google.accompanist:accompanist-placeholder-material:$accompanist_version" 131 | implementation "com.google.accompanist:accompanist-swiperefresh:$accompanist_version" 132 | //retrofit 133 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 134 | implementation 'com.squareup.retrofit2:converter-gson:2.9.0' 135 | //折叠工具栏 136 | implementation "me.onebone:toolbar-compose:2.3.4" 137 | implementation "br.com.devsrsouza.compose.icons.android:font-awesome:1.0.0" 138 | //decompose 139 | implementation "com.arkivanov.decompose:extensions-compose-jetpack:0.8.0" 140 | } 141 | 142 | kapt { 143 | correctErrorTypes true 144 | javacOptions { 145 | // These options are normally set automatically via the Hilt Gradle plugin, but we 146 | // set them manually to workaround a bug in the Kotlin 1.5.20 147 | option("-Adagger.fastInit=ENABLED") 148 | option("-Adagger.hilt.android.internal.disableAndroidSuperclassValidation=true") 149 | } 150 | } 151 | 152 | protobuf { 153 | protoc { 154 | artifact = "com.google.protobuf:protoc:3.10.0" 155 | } 156 | 157 | // Generates the java Protobuf-lite code for the Protobufs in this project. See 158 | // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation 159 | // for more information. 160 | generateProtoTasks { 161 | all().each { task -> 162 | task.builtins { 163 | java { 164 | option 'lite' 165 | } 166 | } 167 | } 168 | } 169 | } 170 | 171 | //aabResGuard { 172 | // whiteList = [ // White list rules 173 | // "*.R.raw.*", 174 | // "*.R.drawable.icon", 175 | // "*.R.mipmap.ic_launcher", 176 | // "*.R.mipmap.logo", 177 | // "*.R.string.default_web_client_id", 178 | // "*.R.string.firebase_database_url", 179 | // "*.R.string.gcm_defaultSenderId", 180 | // "*.R.string.google_api_key", 181 | // "*.R.string.google_app_id", 182 | // "*.R.string.google_crash_reporting_api_key", 183 | // "*.R.string.google_storage_bucket", 184 | // "*.R.string.project_id", 185 | // "*.R.string.com.crashlytics.android.build_id", 186 | // "*.R.string.tt_*", 187 | // "*.R.layout.tt_*", 188 | // "*.R.drawable.tt_*", 189 | // "*.R.layout.notification_*", 190 | // "*.R.string.star_*", 191 | // "*.R.dimen.tt_*", 192 | // "*.R.integer.tt_*", 193 | // "*.R.anim.tt_*", 194 | // "*.R.color.tt_*", 195 | // "*.R.style.tt_*", 196 | // "*.R.style.Theme.Dialog.TT_*", 197 | // "*.R.style.quick_*", 198 | // "*.R.style.EditTextStyle*", 199 | // "*.R.id.tt_*" 200 | // ] 201 | // obfuscatedBundleFileName = "compose_many.aab" 202 | // // Obfuscated file name, must end with '.aab' 203 | // mergeDuplicatedRes = false // Whether to allow the merge of duplicate resources 204 | // enableFilterFiles = false // Whether to allow filter files 205 | // filterList = [ // file filter rules 206 | // "META-INF/*" 207 | // ] 208 | // enableFilterStrings = false // switch of filter strings 209 | // unusedStringPath = file("unused.txt").toPath() // strings will be filtered in this file 210 | // languageWhiteList = [] // keep en,en-xx,zh,zh-xx etc. remove others. 211 | //} -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/playsong/CommentsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.playsong 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import androidx.paging.* 7 | import com.mrlin.composemany.repository.NetEaseMusicApi 8 | import com.mrlin.composemany.repository.entity.Comment 9 | import com.mrlin.composemany.repository.entity.CommentData 10 | import com.mrlin.composemany.repository.entity.FloorCommentData 11 | import com.mrlin.composemany.repository.entity.Song 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.launch 16 | import retrofit2.await 17 | import retrofit2.awaitResponse 18 | import java.util.* 19 | import javax.inject.Inject 20 | import kotlin.collections.ArrayList 21 | 22 | /** 23 | * 评论列表 24 | */ 25 | @HiltViewModel 26 | class CommentsViewModel @Inject constructor( 27 | private val musicApi: NetEaseMusicApi, 28 | savedStateHandle: SavedStateHandle 29 | ) : ViewModel() { 30 | private val _commentCount = MutableStateFlow(0) 31 | private val _commentSortType = MutableStateFlow(CommentData.SortType.RECOMMEND) 32 | private val _floorComment = MutableStateFlow(FloorCommentData()) 33 | private val _replyToComment = MutableStateFlow(null) 34 | 35 | val commentCount: StateFlow = _commentCount 36 | val commentSortType: StateFlow = _commentSortType 37 | val floorComment: StateFlow = _floorComment 38 | val replyToComment: StateFlow = _replyToComment 39 | private val _song = savedStateHandle.get("song") 40 | private val commentCache: MutableList = mutableListOf() 41 | private var _currentSource: MemoryCachePagingSource? = null 42 | 43 | @OptIn(ExperimentalPagingApi::class) 44 | var commentsPager = Pager( 45 | PagingConfig(pageSize = 10), 46 | remoteMediator = CommentsRemoteMediator( 47 | musicApi, 48 | _song?.id ?: 0, 49 | _commentCount, 50 | commentCache 51 | ) 52 | ) { 53 | MemoryCachePagingSource(commentCache).also { 54 | _currentSource = it 55 | } 56 | } 57 | 58 | fun changeRankType(commentRankType: CommentData.SortType) { 59 | _commentSortType.value = commentRankType 60 | } 61 | 62 | /** 63 | * 载入楼层回复评论 64 | */ 65 | fun loadFloorReply(commentId: Long) = viewModelScope.launch { 66 | try { 67 | _floorComment.value = FloorCommentData() 68 | val floorComment = musicApi.floorComment(commentId, _song?.id ?: 0).await() 69 | if (floorComment.code == 200) { 70 | _floorComment.value = floorComment.data 71 | } 72 | } catch (t: Throwable) { 73 | t.printStackTrace() 74 | } 75 | } 76 | 77 | /** 78 | * 点赞/取消点赞主楼评论 79 | */ 80 | fun toggleMainCommentLike(comment: Comment) = viewModelScope.launch { 81 | try { 82 | val like = !comment.liked 83 | likeComment(comment, like) 84 | _currentSource?.invalidate() 85 | } catch (t: Throwable) { 86 | t.printStackTrace() 87 | } 88 | } 89 | 90 | /** 91 | * 点赞/取消点赞楼层中评论 92 | */ 93 | fun toggleFloorCommentLike(comment: Comment) = viewModelScope.launch { 94 | try { 95 | val like = !comment.liked 96 | likeComment(comment, like) 97 | _floorComment.value = _floorComment.value.run { 98 | FloorCommentData( 99 | hasMore, totalCount, Date().time, comments, ownerComment 100 | ) 101 | } 102 | } catch (t: Throwable) { 103 | t.printStackTrace() 104 | } 105 | } 106 | 107 | /** 108 | * 点赞/取消点赞 109 | */ 110 | @Throws(Throwable::class) 111 | private suspend fun likeComment(comment: Comment, isLike: Boolean) { 112 | val response = 113 | musicApi.likeComment(_song?.id ?: 0L, comment.commentId, if (isLike) 1 else 0) 114 | .awaitResponse() 115 | if (response.isSuccessful) { 116 | comment.liked = isLike 117 | if (isLike) { 118 | comment.likedCount++ 119 | } else { 120 | comment.likedCount-- 121 | } 122 | } else { 123 | throw Throwable("点赞/取消点赞失败!") 124 | } 125 | } 126 | 127 | //发表评论 128 | fun publishComment(content: String) = viewModelScope.launch { 129 | try { 130 | val op = if (_replyToComment.value == null) CommentData.Op.PUBLISH else CommentData.Op.REPLY 131 | operateComment(op, content = content, commentId = _replyToComment.value?.commentId) 132 | if (op == CommentData.Op.PUBLISH) { 133 | _commentCount.value++ 134 | } else { 135 | commentCache.find { it.commentId == _replyToComment.value?.commentId }?.showFloorComment?.let { 136 | it.replyCount++ 137 | } 138 | } 139 | _currentSource?.invalidate() 140 | } catch (t: Throwable) { 141 | t.printStackTrace() 142 | } 143 | } 144 | 145 | //删除评论 146 | fun deleteComment(commentId: Long?) = viewModelScope.launch { 147 | try { 148 | operateComment(CommentData.Op.DELETE, commentId = commentId) 149 | _commentCount.value-- 150 | _currentSource?.invalidate() 151 | } catch (t: Throwable) { 152 | t.printStackTrace() 153 | } 154 | } 155 | 156 | //删除楼层评论 157 | fun deleteFloorComment(commentId: Long?) = viewModelScope.launch { 158 | try { 159 | operateComment(CommentData.Op.DELETE, commentId = commentId) 160 | _floorComment.value = _floorComment.value.run { 161 | val commentList = comments.toMutableList() 162 | commentList.removeAll { it.commentId == commentId } 163 | FloorCommentData( 164 | hasMore, totalCount - 1, Date().time, commentList.toList(), ownerComment 165 | ) 166 | } 167 | } catch (t: Throwable) { 168 | t.printStackTrace() 169 | } 170 | } 171 | 172 | //更改回复的评论对象 173 | fun changeReplyTo(comment: Comment?) { 174 | _replyToComment.value = comment 175 | } 176 | 177 | //操作评论 178 | @Throws(Throwable::class) 179 | private suspend fun operateComment( 180 | op: CommentData.Op = CommentData.Op.PUBLISH, 181 | content: String? = null, 182 | commentId: Long? = null 183 | ) { 184 | val response = musicApi.comment( 185 | operation = op, id = _song?.id ?: 0L, content = content, 186 | commentId = commentId 187 | ).awaitResponse() 188 | if (response.isSuccessful) { 189 | if (op == CommentData.Op.PUBLISH) { 190 | response.body()?.comment?.let { commentCache.add(0, it) } 191 | } else if (op == CommentData.Op.DELETE) { 192 | commentCache.removeAll { it.commentId == commentId } 193 | } 194 | } else { 195 | throw Throwable("评论操作失败") 196 | } 197 | } 198 | 199 | /** 200 | * 缓存数据用于界面显示 201 | */ 202 | private class MemoryCachePagingSource( 203 | val commentCache: MutableList 204 | ) : PagingSource() { 205 | override fun getRefreshKey(state: PagingState): Int? = null 206 | 207 | override suspend fun load(params: LoadParams): LoadResult { 208 | val pageNum = params.key ?: 1 209 | val pageSize = 10 210 | val pageStartIndex = (pageNum - 1) * pageSize 211 | val (endIndex, nextKey) = if (pageSize * pageNum > commentCache.size) { 212 | Pair(commentCache.size, null) 213 | } else { 214 | Pair(pageNum * pageSize, pageNum + 1) 215 | } 216 | return LoadResult.Page( 217 | data = ArrayList( 218 | commentCache.subList( 219 | pageStartIndex, 220 | endIndex.coerceAtLeast(pageStartIndex) 221 | ) 222 | ), 223 | prevKey = null, 224 | nextKey = nextKey 225 | ) 226 | } 227 | 228 | override val keyReuseSupported: Boolean 229 | get() = true 230 | } 231 | 232 | /** 233 | * 加载云端评论数据 234 | */ 235 | @ExperimentalPagingApi 236 | private inner class CommentsRemoteMediator( 237 | private val musicApi: NetEaseMusicApi, 238 | private val songId: Long, 239 | private val commentCount: MutableStateFlow, 240 | private val commentCache: MutableList, 241 | ) : RemoteMediator() { 242 | private var lastCursor: Long? = null 243 | override suspend fun load( 244 | loadType: LoadType, 245 | state: PagingState 246 | ): MediatorResult { 247 | val pageNum = when (loadType) { 248 | LoadType.REFRESH -> 1 249 | else -> state.pages.size + 1 250 | } 251 | val response = musicApi.commentData( 252 | songId, 253 | pageNo = pageNum, 254 | pageSize = state.config.pageSize, 255 | cursor = lastCursor, 256 | sortType = _commentSortType.value 257 | ).await() 258 | commentCount.value = response.data.totalCount 259 | if (_commentSortType.value == CommentData.SortType.NEWEST) { 260 | lastCursor = response.data.comments.lastOrNull()?.time 261 | } 262 | when (loadType) { 263 | LoadType.REFRESH -> { 264 | commentCache.clear() 265 | commentCache.addAll(response.data.comments) 266 | _currentSource?.invalidate() 267 | } 268 | LoadType.APPEND -> { 269 | commentCache.addAll(response.data.comments) 270 | _currentSource?.invalidate() 271 | } 272 | else -> { 273 | } 274 | } 275 | return MediatorResult.Success( 276 | endOfPaginationReached = !response.data.hasMore 277 | ) 278 | } 279 | 280 | } 281 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mrlin/composemany/pages/music/playsong/PlaySongFragment.kt: -------------------------------------------------------------------------------- 1 | package com.mrlin.composemany.pages.music.playsong 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.compose.animation.core.* 8 | import androidx.compose.foundation.Image 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.layout.* 11 | import androidx.compose.foundation.lazy.LazyColumn 12 | import androidx.compose.foundation.lazy.itemsIndexed 13 | import androidx.compose.foundation.lazy.rememberLazyListState 14 | import androidx.compose.material.* 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.filled.ArrowBack 17 | import androidx.compose.runtime.* 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.BiasAlignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.draw.drawWithContent 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.graphics.TransformOrigin 24 | import androidx.compose.ui.graphics.graphicsLayer 25 | import androidx.compose.ui.layout.ContentScale 26 | import androidx.compose.ui.platform.LocalContext 27 | import androidx.compose.ui.res.painterResource 28 | import androidx.compose.ui.text.TextStyle 29 | import androidx.compose.ui.text.style.TextAlign 30 | import androidx.compose.ui.tooling.preview.Preview 31 | import androidx.compose.ui.unit.dp 32 | import androidx.compose.ui.unit.sp 33 | import androidx.fragment.app.Fragment 34 | import androidx.fragment.app.activityViewModels 35 | import androidx.fragment.app.viewModels 36 | import androidx.navigation.fragment.findNavController 37 | import coil.compose.rememberImagePainter 38 | import coil.transform.BlurTransformation 39 | import coil.transform.CircleCropTransformation 40 | import com.mrlin.composemany.R 41 | import com.mrlin.composemany.pages.music.MusicScreen 42 | import com.mrlin.composemany.pages.music.PlaySongsViewModel 43 | import com.mrlin.composemany.pages.music.home.composeContent 44 | import com.mrlin.composemany.pages.music.widgets.MiniButton 45 | import com.mrlin.composemany.repository.entity.Song 46 | import com.mrlin.composemany.repository.entity.SongCommentData 47 | import com.mrlin.composemany.repository.entity.limitSize 48 | import com.mrlin.composemany.ui.theme.ComposeManyTheme 49 | import com.mrlin.composemany.utils.simpleNumText 50 | import dagger.hilt.android.AndroidEntryPoint 51 | import kotlin.math.abs 52 | 53 | /********************************* 54 | * 歌曲播放 55 | * @author mrlin 56 | * 创建于 2021年09月06日 57 | ******************************** */ 58 | @AndroidEntryPoint 59 | class PlaySongFragment : Fragment() { 60 | private val playSongViewModel by activityViewModels() 61 | private val viewModel by viewModels() 62 | 63 | override fun onCreateView( 64 | inflater: LayoutInflater, 65 | container: ViewGroup?, 66 | savedInstanceState: Bundle? 67 | ): View = composeContent { 68 | val curSong by playSongViewModel.curSong.collectAsState() 69 | val curProgress by playSongViewModel.curProgress.collectAsState() 70 | val isPlaying by playSongViewModel.isPlaying.collectAsState() 71 | val commentData by viewModel.songComment.collectAsState() 72 | val likeList by playSongViewModel.likeList.collectAsState() 73 | val lyric by playSongViewModel.lyric.collectAsState() 74 | LaunchedEffect(key1 = curSong, block = { 75 | //歌曲更换后自动载入对应评论 76 | viewModel.loadComment(curSong ?: return@LaunchedEffect) 77 | }) 78 | ComposeManyTheme { 79 | val likeSong = likeList.contains(curSong?.id) 80 | PlaySong(song = curSong, curProgress, isPlaying, commentData, likeSong, lyric) { 81 | when (it) { 82 | is Event.TrySeek -> playSongViewModel.trySeek(it.progress) 83 | is Event.Seek -> playSongViewModel.seekPlay() 84 | is Event.TogglePlay -> playSongViewModel.togglePlay() 85 | is Event.Back -> findNavController().navigateUp() 86 | is Event.ToComments -> findNavController().navigate( 87 | MusicScreen.SongComment(curSong ?: return@PlaySong).directions 88 | ) 89 | is Event.PreviousSong -> playSongViewModel.prevPlay() 90 | is Event.NextSong -> playSongViewModel.nextPlay() 91 | is Event.LikeSong -> playSongViewModel.toggleLike() 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * 歌曲播放主界面 100 | */ 101 | @Composable 102 | private fun PlaySong( 103 | song: Song?, 104 | progress: Float = 0f, 105 | isPlaying: Boolean = false, 106 | commentData: SongCommentData? = null, 107 | likeSong: Boolean = false, 108 | lyric: List> = emptyList(), 109 | onEvent: ((Event) -> Unit)? = null 110 | ) { 111 | Scaffold( 112 | topBar = { 113 | TopAppBar(title = { 114 | Column { 115 | Text(text = song?.name.orEmpty()) 116 | Text( 117 | text = song?.artists.orEmpty(), 118 | style = TextStyle(fontSize = 12.sp, color = Color.White) 119 | ) 120 | } 121 | }, navigationIcon = { 122 | IconButton(onClick = { onEvent?.invoke(Event.Back) }) { 123 | Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null) 124 | } 125 | }) 126 | } 127 | ) { 128 | //模糊虚化的封面作为背景 129 | Image( 130 | painter = rememberImagePainter(song?.picUrl?.limitSize(200), builder = { 131 | transformations(BlurTransformation(LocalContext.current, 16f)) 132 | }), 133 | contentDescription = null, 134 | modifier = Modifier 135 | .fillMaxSize() 136 | .drawWithContent { 137 | drawContent() 138 | //背景遮上半透明颜色,改善明亮色调的背景下,白色操作按钮的显示效果 139 | drawRect(Color.Gray, alpha = 0.7f) 140 | }, 141 | contentScale = ContentScale.FillHeight 142 | ) 143 | Column(modifier = Modifier.padding(bottom = 12.dp)) { 144 | var showLyric by remember { mutableStateOf(false) } 145 | val cdAreaModifier = Modifier 146 | .fillMaxWidth() 147 | .weight(1.0f) 148 | .clickable { showLyric = !showLyric } 149 | if (showLyric) { 150 | //歌词显示 151 | Lyric(cdAreaModifier, lyric, progress) 152 | } else { 153 | //唱片显示 154 | CD(isPlaying, song, modifier = cdAreaModifier) 155 | } 156 | //评论、收藏等歌曲操作 157 | Row( 158 | modifier = Modifier 159 | .fillMaxWidth() 160 | .padding(horizontal = 16.dp) 161 | .height(64.dp), horizontalArrangement = Arrangement.SpaceEvenly 162 | ) { 163 | MiniButton( 164 | if (likeSong) R.drawable.icon_like else R.drawable.icon_dislike, 165 | tint = if (likeSong) Color.Red else Color.LightGray 166 | ) { 167 | onEvent?.invoke(Event.LikeSong) 168 | } 169 | MiniButton(R.drawable.icon_song_download) 170 | MiniButton(R.drawable.bfc) 171 | Box(modifier = Modifier.fillMaxHeight()) { 172 | MiniButton(R.drawable.icon_song_comment) { onEvent?.invoke(Event.ToComments) } 173 | Text( 174 | text = commentData?.total?.toLong()?.simpleNumText().orEmpty(), 175 | modifier = Modifier.align(BiasAlignment(0.75f, -0.75f)), 176 | style = TextStyle(color = Color.White, fontSize = 10.sp) 177 | ) 178 | } 179 | MiniButton(R.drawable.icon_song_more) 180 | } 181 | //歌曲进度 182 | Slider(value = progress, onValueChange = { 183 | onEvent?.invoke(Event.TrySeek(it)) 184 | }, onValueChangeFinished = { 185 | onEvent?.invoke(Event.Seek) 186 | }, modifier = Modifier 187 | .height(48.dp) 188 | .padding(10.dp) 189 | ) 190 | //播放操作 191 | Row( 192 | modifier = Modifier 193 | .height(64.dp) 194 | .fillMaxWidth(), 195 | horizontalArrangement = Arrangement.Center, 196 | verticalAlignment = Alignment.CenterVertically 197 | ) { 198 | MiniButton(R.drawable.icon_song_play_type_1) 199 | MiniButton(R.drawable.icon_song_left) { 200 | onEvent?.invoke(Event.PreviousSong) 201 | } 202 | MiniButton(if (isPlaying) R.drawable.icon_song_pause else R.drawable.icon_song_play) { 203 | onEvent?.invoke(Event.TogglePlay) 204 | } 205 | MiniButton(R.drawable.icon_song_right) { 206 | onEvent?.invoke(Event.NextSong) 207 | } 208 | MiniButton(R.drawable.icon_play_songs) 209 | } 210 | } 211 | } 212 | } 213 | 214 | @Composable 215 | private fun CD(isPlaying: Boolean, song: Song?, modifier: Modifier) { 216 | Box( 217 | modifier = modifier.padding(horizontal = 36.dp) 218 | ) { 219 | //唱片旋转角度 220 | val rotation = infiniteRotation(isPlaying) 221 | //唱针旋转角度 222 | val stylusRotation by animateFloatAsState(targetValue = if (isPlaying) 0f else -30f) 223 | //歌曲封面 224 | Image( 225 | painter = rememberImagePainter(song?.picUrl?.limitSize(200), builder = { 226 | transformations(CircleCropTransformation()) 227 | }), 228 | contentDescription = null, 229 | contentScale = ContentScale.Crop, 230 | modifier = Modifier 231 | .align(Alignment.Center) 232 | .matchParentSize() 233 | .aspectRatio(1.0f) 234 | .padding(20.dp) 235 | .graphicsLayer { 236 | rotationZ = rotation.value 237 | } 238 | ) 239 | //唱片边框 240 | Image( 241 | painter = painterResource(id = R.drawable.bet), 242 | contentDescription = null, 243 | contentScale = ContentScale.Crop, 244 | modifier = Modifier 245 | .align(Alignment.Center) 246 | .matchParentSize() 247 | .aspectRatio(1.0f) 248 | .padding(10.dp), 249 | ) 250 | //唱片针 251 | Image( 252 | painter = painterResource(id = R.drawable.bgm), 253 | contentDescription = null, 254 | modifier = Modifier 255 | .align(BiasAlignment(0.3f, -1f)) 256 | .graphicsLayer { 257 | rotationZ = stylusRotation 258 | transformOrigin = TransformOrigin(0f, 0f) 259 | } 260 | ) 261 | } 262 | } 263 | 264 | @Composable 265 | private fun Lyric(modifier: Modifier, lyric: List>, progress: Float) { 266 | if (lyric.isEmpty()) { 267 | Text(text = "暂无歌词", color = Color.White, textAlign = TextAlign.Center, modifier = modifier) 268 | return 269 | } 270 | val maxTime = lyric.last().first 271 | //TODO 还没想到更好的定位当前播放的歌词位置,先使用进度估算 272 | val curPosition = lyric.indexOfFirst { 273 | val lyricProcess = it.first * 1.0f / maxTime 274 | abs(progress - lyricProcess) < 0.01 275 | } 276 | val lazyListState = rememberLazyListState() 277 | LaunchedEffect(key1 = curPosition, block = { 278 | curPosition.takeIf { it >= 0 }?.let { lazyListState.scrollToItem(it) } 279 | }) 280 | LazyColumn(modifier = modifier.padding(vertical = 16.dp), state = lazyListState) { 281 | itemsIndexed(lyric) { pos, lrcData -> 282 | Text( 283 | text = lrcData.second, 284 | Modifier.fillMaxWidth(), 285 | style = TextStyle( 286 | color = if (pos == curPosition) Color.White else Color.LightGray, 287 | textAlign = TextAlign.Center 288 | ), 289 | ) 290 | } 291 | } 292 | } 293 | 294 | /** 295 | * 无限循环的旋转动画 296 | */ 297 | @Composable 298 | private fun infiniteRotation( 299 | startRotate: Boolean, 300 | duration: Int = 15 * 1000 301 | ): Animatable { 302 | var rotation by remember { mutableStateOf(Animatable(0f)) } 303 | LaunchedEffect(key1 = startRotate, block = { 304 | if (startRotate) { 305 | //从上次的暂停角度 -> 执行动画 -> 到目标角度(+360°) 306 | rotation.animateTo( 307 | (rotation.value % 360f) + 360f, animationSpec = infiniteRepeatable( 308 | animation = tween(duration, easing = LinearEasing) 309 | ) 310 | ) 311 | } else { 312 | rotation.stop() 313 | //初始角度取余是为了防止每次暂停后目标角度无限增大 314 | rotation = Animatable(rotation.value % 360f) 315 | } 316 | }) 317 | return rotation 318 | } 319 | 320 | private sealed class Event { 321 | class TrySeek(val progress: Float) : Event() 322 | 323 | object Seek : Event() 324 | 325 | object TogglePlay : Event() 326 | 327 | object PreviousSong : Event() 328 | 329 | object NextSong : Event() 330 | 331 | object Back : Event() 332 | 333 | object ToComments : Event() 334 | 335 | object LikeSong : Event() 336 | } 337 | 338 | @Preview 339 | @Composable 340 | fun PlaySongPreview() { 341 | PlaySong(song = null) 342 | } 343 | --------------------------------------------------------------------------------