Repository: sskEvan/NCMusicDesktop Branch: master Commit: 378011898276 Files: 107 Total size: 292.2 KB Directory structure: gitextract_hkxa2m6e/ ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── artifacts/ │ │ └── NCMusicDesktop_jvm_1_0_SNAPSHOT.xml │ ├── gradle.xml │ ├── kotlinc.xml │ ├── misc.xml │ ├── uiDesigner.xml │ └── vcs.xml ├── README.md ├── build.gradle.kts ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── launcher/ │ └── icon.icns ├── proguard-rules.pro ├── settings.gradle.kts └── src/ └── jvmMain/ ├── kotlin/ │ ├── Main.kt │ ├── base/ │ │ ├── AppConfig.kt │ │ ├── BaseViewModel.kt │ │ ├── MusicPlayController.kt │ │ ├── UserManager.kt │ │ └── player/ │ │ ├── IPlayer.kt │ │ ├── IPlayerListener.kt │ │ ├── NCPlayer.kt │ │ ├── PlayMode.kt │ │ └── PlayerStatus.kt │ ├── http/ │ │ ├── RetrofitClient.kt │ │ ├── api/ │ │ │ └── NCApi.kt │ │ └── interceptor/ │ │ └── CookieInterceptor.kt │ ├── model/ │ │ ├── BasePagingBean.kt │ │ ├── BaseResult.kt │ │ ├── CommentResult.kt │ │ ├── LoginResult.kt │ │ ├── LyricResult.kt │ │ ├── NewSongResult.kt │ │ ├── PlayListResult.kt │ │ ├── PrivateContentResult.kt │ │ ├── RecommendMVResult.kt │ │ └── SongDetailResult.kt │ ├── router/ │ │ ├── NavGraph.kt │ │ └── RouterUrls.kt │ ├── ui/ │ │ ├── common/ │ │ │ ├── CommonImage.kt │ │ │ ├── CommonTabLayout.kt │ │ │ ├── CpnActionMore.kt │ │ │ ├── ExpandableText.kt │ │ │ ├── ListToGridItems.kt │ │ │ ├── LoadingComponent.kt │ │ │ ├── ModifierExt.kt │ │ │ ├── NoSuccessComponent.kt │ │ │ ├── PaingFooterNumBar.kt │ │ │ ├── SeekBar.kt │ │ │ ├── TableLayout.kt │ │ │ ├── ViewStateComponent.kt │ │ │ ├── ViewStateLazyGridPagingComponent.kt │ │ │ └── toast/ │ │ │ └── Toast.kt │ │ ├── discovery/ │ │ │ ├── DiscoveryPage.kt │ │ │ └── cpn/ │ │ │ ├── CpnHighQualityPlayListEntrance.kt │ │ │ ├── CpnNewSongEntrance.kt │ │ │ ├── CpnPersonalRecommend.kt │ │ │ ├── CpnPlayListItem.kt │ │ │ ├── CpnPlayListTabSelectedBar.kt │ │ │ ├── CpnPrivateContentEntrance.kt │ │ │ ├── CpnRecommandPlayListEntrance.kt │ │ │ ├── CpnRecommendPlayList.kt │ │ │ └── CpnRecommentMVEntrance.kt │ │ ├── login/ │ │ │ └── QrcodeLoginDialog.kt │ │ ├── main/ │ │ │ ├── MainPage.kt │ │ │ └── cpn/ │ │ │ ├── CpnMainLeftMenu.kt │ │ │ ├── CpnMainMusicPlayDrawer.kt │ │ │ ├── CpnMainRightContainer.kt │ │ │ ├── CpnMusicPlayBottomBar.kt │ │ │ ├── CpnPlaformDecoratedButtons.kt │ │ │ └── CpnThemePopup.kt │ │ ├── play/ │ │ │ ├── CpnCurrentPlayListSheet.kt │ │ │ ├── CpnLyric.kt │ │ │ ├── CpnMusicPlay.kt │ │ │ └── CpnSongInfo.kt │ │ ├── playlist/ │ │ │ ├── PlayListDetailPage.kt │ │ │ └── cpn/ │ │ │ ├── CpnPlayListCommentList.kt │ │ │ ├── CpnPlayListSubscribers.kt │ │ │ └── CpnTrackList.kt │ │ ├── setting/ │ │ │ └── SettingPage.kt │ │ ├── theme/ │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── color/ │ │ │ ├── AppColors.kt │ │ │ └── palette/ │ │ │ ├── dark/ │ │ │ │ └── DartColorPalette.kt │ │ │ └── light/ │ │ │ ├── BlueColorPalette.kt │ │ │ ├── DefaultColorPalette.kt │ │ │ ├── GreenColorPalette.kt │ │ │ ├── OriginColorPalette.kt │ │ │ ├── PurpleColorPalette.kt │ │ │ └── YellowColorPalette.kt │ │ └── todo/ │ │ ├── TestPage.kt │ │ └── TodoPage.kt │ └── util/ │ ├── DataStoreUtils.kt │ ├── DensityExt.kt │ ├── EnvUtil.kt │ ├── LyricUtil.kt │ ├── QrcodeUtil.kt │ ├── StringUtil.kt │ ├── TimeUtil.kt │ └── createDataStore.kt └── resources/ └── image/ ├── ic_empty.xml ├── ic_load_error.xml └── ic_network_error.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ ### IntelliJ IDEA ### .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml .idea/libraries/ *.iws *.iml *.ipr out/ !**/src/main/**/out/ !**/src/test/**/out/ ### Eclipse ### .apt_generated .classpath .factorypath .project .settings .springBeans .sts4-cache bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ ### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ ### VS Code ### .vscode/ ### Mac OS ### .DS_Store ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml ================================================ FILE: .idea/artifacts/NCMusicDesktop_jvm_1_0_SNAPSHOT.xml ================================================ $PROJECT_DIR$/build/libs ================================================ FILE: .idea/gradle.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: .idea/misc.xml ================================================ ================================================ FILE: .idea/uiDesigner.xml ================================================ ================================================ FILE: .idea/vcs.xml ================================================ ================================================ FILE: README.md ================================================ # NCMusicDesktop 小明非常喜欢网易云,去年刚用Jetpack Compose写了个仿网易云app [NCMusic](https://github.com/sskEvan/NCMusic) ,最近发现compose-jb正式版已经发布到了v1.3.1, 又玩了一下Compose Desktop,决定搞了个桌面版的NCMusicDesktop,数据源还是来自[Binaryify](https://github.com/Binaryify)大佬的[NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)~ 由于以前没有开发桌面应用的经验,索性想按照Android jetpack的套路来开发,然而Navigation、Lifecycle 、ViewModel、LiveData等等这些在compose-jb中,暂时通通没有~ 不要慌,一番查找在掘金上看到一篇文章[《推销 Compose 跨平台 Navigation:PreCompose》](https://juejin.cn/post/7122056172084920334), 讲了[Precompose](https://github.com/Tlaster/PreCompose)这个跨平台Navigation框架的使用, 它基本复刻了Jetpack Navigation、Lifecycle、ViewModel这些组件, 使用方式也基本保持一致,美滋滋!当然LiveData已经被废弃了,推荐使用Flow代替~至于网络请求,Retrofit照用不误,又一次美滋滋~ ### 怎么用Android老套路来写Desktop应用 - 老规矩,先定义一波BaseResult、BaseViewModel、ViewStateComponent(页面状态切换组件) ``` 代码: 略 ``` - Model层 ``` class LyricResult( val transUser: LyricContributorBean?, val lyricUser: LyricContributorBean?, val lrc: LrcBean?, val tlyric: LrcBean? ) : BaseResult() ``` - ViewModel层 ``` class CpnLyricViewModel : BaseViewModel() { fun getLyric(id: Long) = launchFlow { NCRetrofitClient.getNCApi().getLyric(id) } } interface NCApi { @GET("/lyric") suspend fun getLyric(@Query("id") id: Long): LyricResult } ``` - View层 ``` @Composable fun CpnLyric() { ViewStateComponent( key = "CpnLyric-${id}", loadDataBlock = {viewModel.getLyric(id)} ) { LyricList(it) } } ``` ### 怎么播放音乐 至于在Compose Desktop上怎么播放音乐呢,毕竟没有Android的MediaPlayer,在github上找了找,发现[succlz123](https://github.com/succlz123)大佬开源的Compose Multiplatform项目 [AcFun-Client-Multiplatform](https://github.com/succlz123/AcFun-Client-Multiplatform),里面有视频播放的功能,是基于[vlcj](https://github.com/caprica/vlcj)来实现的,看了下vlcj的api,使用AudioPlayerComponent播放音乐不是问题 ### 关于嵌套滑动 开发过程中,有些交互感觉需要涉及到嵌套滑动,在Jetpack Compose中,使用NestedScrollConnection来处理嵌套滑动到场景,于是乎,写了一堆✨✨代码后, 发现NestedScrollConnection在Compose Desktop中完全不起作用,后面找了下github的issue,发现有哥们也遇到了哈哈哈,然而官方21年的回复是暂时没有计划, 到现在还是没有解决,凉飕飕~ ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/nested_issue1.webp) ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/nested_issue2.webp) ### 第三方框架 - [PreCompose](https://github.com/Tlaster/PreCompose) - [zxing](https://github.com/zxing/zxing) - [compose-imageloader-desktop](https://github.com/succlz123/compose-desktop-imageloader) - [vlcj](https://github.com/caprica/vlcj) ### 运行效果图 ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/登录.gif) ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/首页.gif) ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/歌单列表.gif) ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/歌单详情.gif) ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/音乐播放.gif) ![img](https://github.com/sskEvan/NCMusicDesktop/blob/master/readme/主题切换.gif) ================================================ FILE: build.gradle.kts ================================================ import org.jetbrains.compose.compose import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { kotlin("multiplatform") id("org.jetbrains.compose") } group = "com.ssk.NCMusicDesktop" version = "1.0-SNAPSHOT" repositories { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") maven("https://jitpack.io") } tasks.withType { kotlinOptions.jvmTarget = "11" } kotlin { jvm { jvmToolchain(11) withJava() } sourceSets { val jvmMain by getting { dependencies { implementation(compose.desktop.currentOs) implementation("com.google.zxing:javase:3.3.3") implementation("moe.tlaster:precompose:1.3.14") implementation("androidx.datastore:datastore-preferences-core:1.1.0-dev01") implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("io.github.succlz123:compose-imageloader-desktop:0.0.2") implementation("uk.co.caprica:vlcj:4.7.3") } } val jvmTest by getting } } compose.desktop { application { javaHome = "/Users/anmin83/Library/Java/JavaVirtualMachines/corretto-17.0.6/Contents/Home" mainClass = "MainKt" nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Exe) packageName = "NCMusicDesktop" packageVersion = "1.0.0" // includeAllModules = true modules("java.instrument", "java.sql", "jdk.unsupported") macOS { // a version for all macOS distributables packageVersion = "1.0.0" // a version only for the dmg package dmgPackageVersion = "1.0.0" // a version only for the pkg package pkgPackageVersion = "1.0.0" // 显示在菜单栏、“关于”菜单项、停靠栏等中的应用程序名称 dockName = "NCMusicDesktop" // a build version for all macOS distributables packageBuildVersion = "1.0.0" // a build version only for the dmg package dmgPackageBuildVersion = "1.0.0" // a build version only for the pkg package pkgPackageBuildVersion = "1.0.0" // 设置图标 iconFile.set(project.file("launcher/icon.icns")) } windows { // a version for all Windows distributables packageVersion = "1.0.0" // a version only for the msi package msiPackageVersion = "1.0.0" // a version only for the exe package exePackageVersion = "1.0.0" // 设置图标 iconFile.set(project.file("launcher/icon.ico")) } } buildTypes.release.proguard { obfuscate.set(false) configurationFiles.from(project.file("proguard-rules.pro")) } } } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ kotlin.code.style=official kotlin.version=1.7.20 agp.version=7.3.0 compose.version=1.3.1 ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: proguard-rules.pro ================================================ # DataStore 混淆 -dontwarn androidx.datastore.** # Retrofit2 混淆 -dontwarn javax.annotation.** -dontwarn javax.inject.** # OkHttp3 -dontwarn okhttp3.logging.** -keep class okhttp3.internal.**{*;} -dontwarn okio.** -dontwarn okhttp3.internal.** # Retrofit -dontwarn retrofit2.** -keep class retrofit2.** { *; } -keepattributes Signature -keepattributes Exceptions # Gson -keep class com.google.gson.stream.** { *; } -keepattributes EnclosingMethod ================================================ FILE: settings.gradle.kts ================================================ pluginManagement { repositories { google() gradlePluginPortal() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } plugins { kotlin("multiplatform").version(extra["kotlin.version"] as String) id("org.jetbrains.compose").version(extra["compose.version"] as String) } } rootProject.name = "NCMusicDesktop" ================================================ FILE: src/jvmMain/kotlin/Main.kt ================================================ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.runtime.Composable import androidx.compose.ui.unit.DpSize import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import base.AppConfig import moe.tlaster.precompose.PreComposeWindow import moe.tlaster.precompose.navigation.rememberNavigator import org.succlz123.lib.imageloader.core.ImageLoader import router.NCNavigatorManager import ui.common.theme.AppTheme import ui.common.theme.themeTypeState import ui.main.MainPage import ui.main.cpn.CpnWindowsPlaformDecoratedButtons import util.EnvUtil import java.awt.Dimension import java.io.File fun main() = application { initImageLoader() val windowState = rememberWindowState(size = DpSize(AppConfig.windowMinWidth, AppConfig.windowMinHeight)) PreComposeWindow( state = windowState, onCloseRequest = ::exitApplication, undecorated = EnvUtil.isWindows(), title = "" ) { window.minimumSize = Dimension(AppConfig.windowMinWidth.value.toInt(), AppConfig.windowMinHeight.value.toInt()) window.rootPane.apply { rootPane.putClientProperty("apple.awt.fullWindowContent", true) rootPane.putClientProperty("apple.awt.transparentTitleBar", true) rootPane.putClientProperty("apple.awt.windowTitleVisible", false) } App() CpnWindowsPlaformDecoratedButtons(windowState) } } @Composable @Preview private fun App() { AppTheme(themeTypeState.value) { NCNavigatorManager.navigator = rememberNavigator() MainPage() } } private fun initImageLoader() { ImageLoader.configuration(rootDirectory = File(AppConfig.cacheRootDir)) } ================================================ FILE: src/jvmMain/kotlin/base/AppConfig.kt ================================================ package base import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowState import java.io.File object AppConfig { val topBarHeight = 50.dp val windowMinWidth = 1000.dp val windowMinHeight = 680.dp var fullScreen = false // 应用缓存目录 val cacheRootDir = System.getProperty("user.home") + File.separator + "Library" + File.separator + "NCMusicDesktop" } ================================================ FILE: src/jvmMain/kotlin/base/BaseViewModel.kt ================================================ package base import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import model.BaseResult import moe.tlaster.precompose.viewmodel.ViewModel import moe.tlaster.precompose.viewmodel.viewModelScope typealias ViewStateMutableStateFlow = MutableStateFlow> open class BaseViewModel : ViewModel() { protected fun launchFlow( handleSuccessBlock: ((T) -> Unit)? = null, handleFailBlock: ((code: Int?, message: String?) -> Unit)? = null, judgeEmpty: ((T) -> Boolean)? = null, call: suspend () -> T ): ViewStateMutableStateFlow { val flow = MutableStateFlow>(ViewState.Loading) viewModelScope.launch { runCatching { call() }.onSuccess { result -> if (result.resultOk()) { if (result.isEmpty() || judgeEmpty?.invoke(result) == true) { flow.emit(ViewState.Empty) } else { handleSuccessBlock?.invoke(result) flow.emit(ViewState.Success(result)) } } else { handleFailBlock?.invoke(result.code ?: -1, result.message ?: "请求出错") flow.emit(ViewState.Fail(result.code?.toString() ?: "-1", result.message ?: "请求出错")) } }.onFailure { e -> flow.emit(ViewState.Error(e)) } } return flow } protected fun launch( handleSuccessBlock: ((T) -> Unit)? = null, handleFailBlock: ((code: Int?, message: String?) -> Unit)? = null, call: suspend () -> T ) : Job { return viewModelScope.launch { runCatching { call() }.onSuccess { result -> if (result.resultOk()) { handleSuccessBlock?.invoke(result) } else { handleFailBlock?.invoke(result.code ?: -1, result.message ?: "请求出错") } }.onFailure { e -> handleFailBlock?.invoke(-1000, "请求出错") e.printStackTrace() } } } } sealed class ViewState { object Loading : ViewState() data class Success(val data: T) : ViewState() object Empty : ViewState() data class Fail(val errorCode: String, val errorMsg: String) : ViewState() data class Error(val exception: Throwable) : ViewState() } ================================================ FILE: src/jvmMain/kotlin/base/MusicPlayController.kt ================================================ package base import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import base.player.* import model.SongBean import ui.common.toast.ToastManager import util.StringUtil import java.util.* object MusicPlayController : IPlayerListener { // 是否显示音乐播放抽屉组件 var showMusicPlayDrawer by mutableStateOf(false) // 是否显示播当前播放列表 var showCurPlayListSheet by mutableStateOf(false) // 原始歌曲列表 var originSongList = mutableStateListOf() // 当前播放模式下的实际歌曲列表 var realSongList = mutableStateListOf() var curSongBean by mutableStateOf(null) // 当前播放的歌曲在原始歌曲列表中的索引 var curOriginIndex by mutableStateOf(-1) private set // 当前播放的歌曲在当前播放模式下的实际歌曲列表中的索引 var curRealIndex by mutableStateOf(-1) private set // 当前播放进度 var progress by mutableStateOf(0f) // 当前歌曲播放位置时间文本 var curPositionStr by mutableStateOf("00:00") // 当前歌曲总时长文本 var totalDuringStr by mutableStateOf("00:00") // 是否播放中 private var playing by mutableStateOf(false) // 是否允许拖动进度条 var enableSeeking by mutableStateOf(false) private set // 播放模式 var playMode by mutableStateOf(PlayMode.LOOP) private set // 当前播放歌曲总时长 private var totalDuring = 0 // 是否拖动进度条中 private var seeking = false // 当前播放状态 private var playerStatus: PlayerStatus = PlayerStatus.IDLE val mediaPlayer: IPlayer by lazy { NCPlayer().apply { addListener(this@MusicPlayController) } } /** * 播放音乐列表 */ fun playMusicList(songBeans: List, originIndex: Int) { originSongList.clear() originSongList.addAll(songBeans) println("MusicPlayController playMusicList curOriginIndex=${originIndex}") generateRealSongList(originIndex) innerPlay(originSongList[originIndex]) } /** * 生成当前播放模式下的歌曲列表 */ private fun generateRealSongList(originIndex: Int) { when (playMode) { PlayMode.RANDOM -> { val randomList = mutableListOf() randomList.addAll(originSongList) randomList.shuffle() realSongList.clear() realSongList.addAll(randomList) val realIndex = realSongList.indexOfFirst { it.id == originSongList[originIndex].id } if (realIndex != originIndex) { Collections.swap(realSongList, realIndex, originIndex) } curOriginIndex = originIndex curRealIndex = originIndex } else -> { realSongList.clear() realSongList.addAll(originSongList) curOriginIndex = originIndex curRealIndex = originIndex } } // originSongList.forEachIndexed { index, item -> // println("songList $index --> ${item.name}") // } // println("---------------------------------------") // // realSongList.forEachIndexed { index, item -> // println("pagerSongList $index --> ${item.name}") // } } private fun innerPlay(songBean: SongBean) { curSongBean = songBean mediaPlayer.setDataSource(songBean) mediaPlayer.start() } /** * 根据原始歌曲列表索引播放音乐 */ // fun playByOriginIndex(originIndex: Int) { // if (originSongList.size > originIndex) { // curOriginIndex = originIndex // curRealIndex = realSongList.indexOfFirst { it.id == originSongList[originIndex].id } // innerPlay(originSongList[curOriginIndex]) // } // } /** * 根据实际播放模式中的歌曲列表索引播放音乐 */ fun playByRealIndex(realIndex: Int) { if (originSongList.getOrNull(realIndex) != null) { curRealIndex = realIndex curOriginIndex = originSongList.indexOfFirst { it.id == realSongList[realIndex].id } innerPlay(realSongList[curRealIndex]) } } override fun onStatusChanged(status: PlayerStatus) { playerStatus = status playing = status == PlayerStatus.STARTED enableSeeking = status == PlayerStatus.STARTED || status == PlayerStatus.PAUSED when (status) { PlayerStatus.COMPLETED -> { autoPlayNext() } is PlayerStatus.ERROR -> { println("PlayerStatus.ERROR->${status.errorMsg}") if (status.errorCode != PlayerErrorCode.ERROR_ENV_INVALID) { autoPlayNext() } else { ToastManager.showToast(status.errorMsg, ToastManager.LENGTH_LONG) } } PlayerStatus.STOPPED -> { totalDuringStr = "00:00" curPositionStr = "00:00" this.progress = 0f } else -> {} } } private fun autoPlayNext() { if(playMode == PlayMode.SINGLE) { resume() }else { val newIndex = getNextRealIndex() playByRealIndex(newIndex) } } fun pause() { if (playerStatus == PlayerStatus.STARTED) { mediaPlayer.pause() } } fun resume() { if (playerStatus == PlayerStatus.PAUSED) { mediaPlayer.resume() } } fun isPlaying(): Boolean { return playing } /** * 获取当前播放模式下的上一首歌曲索引 */ fun getPreRealIndex() = if (curRealIndex == 0) realSongList.size - 1 else curRealIndex - 1 /** * 获取当前播放模式下的下一首歌曲索引 */ fun getNextRealIndex() = if (curRealIndex == realSongList.size - 1) 0 else curRealIndex + 1 fun changePlayMode(playMode: PlayMode) { this.playMode = playMode generateRealSongList(curOriginIndex) } fun seekTo(progress: Float) { this.progress = progress if (totalDuring != 0) { mediaPlayer.seekTo(progress) } seeking = false } fun seeking(progress: Float) { seeking = true this.progress = progress if (totalDuring != 0) { this.curPositionStr = StringUtil.formatMilliseconds((progress * totalDuring / 100).toInt()) } } override fun onProgress(totalDuring: Int, currentPosition: Int, percentage: Float) { if (!seeking) { this.totalDuring = totalDuring totalDuringStr = StringUtil.formatMilliseconds(totalDuring) curPositionStr = StringUtil.formatMilliseconds(currentPosition) progress = percentage } } } ================================================ FILE: src/jvmMain/kotlin/base/UserManager.kt ================================================ package base import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import com.google.gson.Gson import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import model.LoginResult import util.DataStoreUtils object UserManager { const val KEY_LOCAL_LOGIN_RESULT = "KEY_LOCAL_LOGIN_RESULT" fun getLoginResultFlow(): Flow { return DataStoreUtils.getData(KEY_LOCAL_LOGIN_RESULT, "") .map { var loginResult: LoginResult? = null try { loginResult = Gson().fromJson(it, LoginResult::class.java) } catch (e: Exception) { e.printStackTrace() } loginResult } } fun getLoginResult(): LoginResult? { return try { val json = DataStoreUtils.readStringData(KEY_LOCAL_LOGIN_RESULT, "") Gson().fromJson(json, LoginResult::class.java) } catch (e: Exception) { null } } suspend fun saveLoginResult(json: String) { DataStoreUtils.putData(KEY_LOCAL_LOGIN_RESULT, json) } } ================================================ FILE: src/jvmMain/kotlin/base/player/IPlayer.kt ================================================ package base.player import model.SongBean /** * Created by ssk on 2022/4/23. */ interface IPlayer { fun setDataSource(songBean: SongBean) fun start() fun pause() fun resume() fun stop() fun seekTo(position: Float) fun envAvailable() = false fun addListener(listener: IPlayerListener) fun removeListener(listener: IPlayerListener) } ================================================ FILE: src/jvmMain/kotlin/base/player/IPlayerListener.kt ================================================ package base.player /** * Created by ssk on 2022/4/23. */ interface IPlayerListener { fun onStatusChanged(status: PlayerStatus) fun onProgress(totalDuring: Int, currentPosition: Int, percentage: Float) } ================================================ FILE: src/jvmMain/kotlin/base/player/NCPlayer.kt ================================================ package base.player import http.NCRetrofitClient import kotlinx.coroutines.* import model.SongBean import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter import uk.co.caprica.vlcj.player.component.AudioPlayerComponent /** * 音乐播放器,基于vlcj */ class NCPlayer : IPlayer { private var mStatus: PlayerStatus = PlayerStatus.IDLE private var mCurSongBean: SongBean? = null private val mListeners = ArrayList() private var mJob: Job? = null private var mMediaPlayer: MediaPlayer? = null private var mDuring: Int = 0 private var mCurTime: Int = 0 init { if (envAvailable()) { mMediaPlayer = AudioPlayerComponent().mediaPlayer().apply { this.events().addMediaPlayerEventListener(object : MediaPlayerEventAdapter() { override fun mediaPlayerReady(mediaPlayer: MediaPlayer) { super.mediaPlayerReady(mediaPlayer) println("----${mCurSongBean?.name}----mediaPlayerReady") innerStartPlay() } override fun finished(mediaPlayer: MediaPlayer) { super.finished(mediaPlayer) setStatus(PlayerStatus.COMPLETED) println("----${mCurSongBean?.name}----finished") } override fun timeChanged(mediaPlayer: MediaPlayer, newTime: Long) { super.timeChanged(mediaPlayer, newTime) mCurTime = newTime.toInt() updateProgress() } override fun opening(mediaPlayer: MediaPlayer?) { super.opening(mediaPlayer) println("----${mCurSongBean?.name}----opening") } override fun playing(mediaPlayer: MediaPlayer?) { super.playing(mediaPlayer) println("----${mCurSongBean?.name}----playing") } override fun paused(mediaPlayer: MediaPlayer?) { super.paused(mediaPlayer) println("----${mCurSongBean?.name}----paused") } override fun stopped(mediaPlayer: MediaPlayer?) { super.stopped(mediaPlayer) println("----${mCurSongBean?.name}----stopped") } override fun error(mediaPlayer: MediaPlayer?) { super.error(mediaPlayer) println("----${mCurSongBean?.name}----error") setStatus(PlayerStatus.ERROR(PlayerErrorCode.ERROR_PLAY, "播放失败")) } }) } } } private fun innerStartPlay() { if (envAvailable()) { println("----${mCurSongBean?.name}----innerStartPlay()") mMediaPlayer?.controls()?.start() setStatus(PlayerStatus.STARTED) } } override fun setDataSource(songBean: SongBean) { mCurSongBean = songBean println("----${mCurSongBean?.name}----setDataSource()") } override fun start() { if (envAvailable()) { println("----${mCurSongBean?.name}----start()") if (mStatus == PlayerStatus.STARTED ) { pause() } mCurSongBean?.let { mDuring = it.dt mCurTime = 0 updateProgress() getSongUrlAndPlay(it.id) } } } private fun getSongUrlAndPlay(songId: Long) { mJob?.cancel() mJob = GlobalScope.launch(context = Dispatchers.IO) { try { val url = NCRetrofitClient.getNCApi().getSongUrl(songId).data.firstOrNull()?.url ?: "https://music.163.com/song/media/outer/url?id=$songId.mp3" mMediaPlayer?.media()?.play(url) } catch (e: Exception) { if (e !is CancellationException) { println("getSongUrlAndPlay e = $e") e.printStackTrace() mListeners.forEach { it.onStatusChanged(PlayerStatus.ERROR(PlayerErrorCode.ERROR_GET_URL, "获取歌曲播放链接失败")) } } } } } fun updateProgress() { if (mDuring != 0) { mListeners.forEach { it.onProgress(mDuring, mCurTime, mCurTime.toFloat() * 100 / mDuring) } } } override fun pause() { if (envAvailable()) { if (mStatus == PlayerStatus.STARTED) { println("----${mCurSongBean?.name}----pause()") mMediaPlayer?.controls()?.pause() setStatus(PlayerStatus.PAUSED) } } } override fun resume() { println("----${mCurSongBean?.name}----resume()") innerStartPlay() } override fun stop() { if (envAvailable()) { println("----${mCurSongBean?.name}----stop()") mMediaPlayer?.controls()?.stop() mDuring = 0 setStatus(PlayerStatus.STOPPED) setStatus(PlayerStatus.IDLE) } } override fun seekTo(position: Float) { if (envAvailable()) { println("----${mCurSongBean?.name}----seekTo->${position}") mMediaPlayer?.controls()?.setPosition(position / 100) } } private fun setStatus(status: PlayerStatus) { mStatus = status mListeners.forEach { it.onStatusChanged(mStatus) } } override fun envAvailable(): Boolean { if (NativeDiscovery().discover()) { return true } else { setStatus(PlayerStatus.ERROR(PlayerErrorCode.ERROR_ENV_INVALID, "NCMusicDesktop播放音乐需依赖VLC组件,当前设备未检测到VLC组件,请前往 https://www.videolan.org/ 下载并安装。")) return false } } override fun addListener(listener: IPlayerListener) { mListeners.add(listener) } override fun removeListener(listener: IPlayerListener) { mListeners.remove(listener) } } ================================================ FILE: src/jvmMain/kotlin/base/player/PlayMode.kt ================================================ package base.player /** * Created by ssk on 2022/4/23. */ enum class PlayMode { // 单曲循环 SINGLE, // 随机 RANDOM, // 列表循环 LOOP, } ================================================ FILE: src/jvmMain/kotlin/base/player/PlayerStatus.kt ================================================ package base.player /** * Created by ssk on 2022/4/23. */ sealed class PlayerStatus() { object IDLE: PlayerStatus() object PREPARED: PlayerStatus() object STARTED: PlayerStatus() object PAUSED: PlayerStatus() object STOPPED: PlayerStatus() object COMPLETED: PlayerStatus() class ERROR(val errorCode: Int, val errorMsg: String): PlayerStatus() } object PlayerErrorCode { // 环境错误,没有安装VLC组件 const val ERROR_ENV_INVALID = 1 // 获取歌曲播放URL错误 const val ERROR_GET_URL = 1 // 播放错误 const val ERROR_PLAY = 2 } ================================================ FILE: src/jvmMain/kotlin/http/RetrofitClient.kt ================================================ package http import http.api.NCApi import http.interceptor.CookieIntercept import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit object NCRetrofitClient { private var ncApi: NCApi? = null fun getNCApi(): NCApi { if (ncApi == null) { ncApi = RetrofitClient.getApi(NCApi::class.java) } return ncApi!! } } object RetrofitClient { const val BASE_URL = "https://ncmusic.sskevan.cn" private const val CONNECT_TIMEOUT = 30L private const val READ_TIMEOUT = 10L fun getApi(retrofit: Class): T = createRetrofit().create(retrofit) private fun createRetrofit(url: String = BASE_URL): Retrofit { // okHttpClientBuilder val okHttpClientBuilder = OkHttpClient().newBuilder().apply { connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) addInterceptor(CookieIntercept()) } return RetrofitBuild( url = url, client = okHttpClientBuilder.build(), gsonFactory = GsonConverterFactory.create() ).retrofit } } class RetrofitBuild( url: String, client: OkHttpClient, gsonFactory: GsonConverterFactory ) { val retrofit: Retrofit = Retrofit.Builder().apply { baseUrl(url) client(client) addConverterFactory(gsonFactory) }.build() } ================================================ FILE: src/jvmMain/kotlin/http/api/NCApi.kt ================================================ package http.api import model.* import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query import java.util.* interface NCApi { /** * 获取推荐歌单 */ @GET("personalized") suspend fun getRecommendPlayList(@Query("limit") limit: Int = 20): RecommendPlayListResult /** * 获取独家放送 */ @GET("personalized/privatecontent") suspend fun getPrivateContent(): PrivateContentResult /** * 新歌速递 */ @GET("/top/song") suspend fun getNewSong(): NewSongResult /** * 新歌速递 */ @GET("/personalized/mv") suspend fun getRecommendMV(): RecommendMVResult /** * 精品歌单 */ @GET("/top/playlist/highquality") suspend fun getHighQualityPlayList(@Query("limit") limit: Int = 20, @Query("cat") cat: String?): PlayListResult /** * 热门歌单分类 */ @GET("/playlist/hot") suspend fun getHotPlayListCategories(): HotPlayListTabResult /** * 歌单分类 */ @GET("/playlist/catlist") suspend fun getPlayListCategories(): PlayListTabResult /** * 歌单列表 */ @GET("/top/playlist") suspend fun getPlayList( @Query("limit") limit: Int = 20, @Query("tag") tag: String, @Query("offset") offset: Int ): PlayListResult /** * 获取歌单详情 */ @GET("playlist/detail") suspend fun getPlaylistDetail(@Query("id") id: Long): PlaylistDetailResult /** * 获取歌曲详情 */ @GET("song/detail") suspend fun getSongDetail(@Query("ids") ids: String): SongDetailResult /** * 获取评论列表 */ @GET("comment/{commentType}") suspend fun getCommentList( @Path("commentType") commentType: String, @Query("id") id: Long, @Query("limit") limit: Int = 20, @Query("offset") offset: Int, // @Query("before") before: Long, // 分页参数,取上一页最后一项的 time 获取下一页数据(获取超过 5000 条评论的时候需要用到) ): CommentResult /** * 获取二维码登录key */ @GET("/login/qr/key") suspend fun getLoginQrcodeKey(@Query("timeStamp") timeStamp: Long = Date().time): QrcodeKeyResult /** * 获取二维码登录链接 */ @GET("/login/qr/create") suspend fun getLoginQrcodeValue( @Query("key") key: String, @Query("timeStamp") timeStamp: Long = Date().time ): QrcodeValueResult /** * 验证二维码登录授权结果 */ @GET("/login/qr/check") suspend fun checkQrcodeAuthStatus( @Query("key") key: String, @Query("timeStamp") timeStamp: Long = Date().time ): QrcodeAuthResult /** * 获取用户信息 */ @GET("/user/account") suspend fun getAccountInfo( @Query("cookie") cookie: String, ): AccountInfoResult /** * 获取用户歌单 */ @GET("user/playlist") suspend fun getUserPlayList(@Query("uid") uid: String): UserPlaylistResult /** * 获取歌曲url */ @GET("/song/url") suspend fun getSongUrl( @Query("id") id: Long, @Query("br") br: Int = 128000 ): SongUrlBean /** * 获取歌词 */ @GET("/lyric") suspend fun getLyric(@Query("id") id: Long): LyricResult } ================================================ FILE: src/jvmMain/kotlin/http/interceptor/CookieInterceptor.kt ================================================ package http.interceptor import androidx.compose.runtime.collectAsState import base.UserManager import okhttp3.Interceptor import okhttp3.Response /** * Created by ssk on 2022/4/24. */ class CookieIntercept : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val mLoginResult = UserManager.getLoginResult() if (mLoginResult != null) { val request = chain.request() val url = if(request.url().toString().contains("?")) { request.url().toString() + "&cookie=" + mLoginResult.cookie }else { request.url().toString() + "?cookie=" + mLoginResult.cookie } val builder = request.newBuilder() builder.get().url(url) val newRequest = builder.build() return chain.proceed(newRequest); } return chain.proceed(chain.request()) } } ================================================ FILE: src/jvmMain/kotlin/model/BasePagingBean.kt ================================================ package model interface IBasePagingBean { fun getTotalCount(): Int } ================================================ FILE: src/jvmMain/kotlin/model/BaseResult.kt ================================================ package model import androidx.annotation.Keep import java.io.Serializable /** * Created by ssk on 2022/4/17. */ @Keep open class BaseResult(val code: Int? = 0, val message: String? = null) : Serializable { open fun resultOk(): Boolean { return code == 200 } open fun isEmpty() = false } ================================================ FILE: src/jvmMain/kotlin/model/CommentResult.kt ================================================ package model import androidx.annotation.Keep @Keep data class CommentResult( val isMusician: Boolean = false, val userId: Long = 0, val total: Int = 0, val more: Boolean = false, val comments: List = emptyList(), val topComments: List = emptyList(), val hotComments: List = emptyList(), ) : BaseResult() { override fun isEmpty() = comments.isEmpty() } @Keep data class CommentBean( val user: CommentUser, val content: String = "", val time: Long = 0, var likedCount: Int = 0, val showFloorComment: FloorComment? = null, val tag: Tag? = null, val commentId: Long = 0L, val beReplied: List? = null, var liked: Boolean = false, ) { @Keep data class Tag( val datas: List? = null, ) @Keep data class TagData( val text: String = "", ) } @Keep data class FloorComment( val replyCount: Long = 0, val showReplyCount: Boolean = false, ) @Keep data class CommentUser( val nickname: String = "", val userId: Long = 0, val avatarUrl: String? = null, ) @Keep data class BeReplied( val user: CommentUser, val content: String? = null, val status: Int = 0, val beRepliedCommentId: Long = 0, ) @Keep data class NewCommentResult( val data : CommentData ) : BaseResult() @Keep data class CommentData( val totalCount: Int = 0, val hasMore: Boolean = false, var cursor: String, val comments: List = emptyList(), ) @Keep data class FloorCommentResult( val data : FloorCommentData ) : BaseResult() @Keep data class FloorCommentData( val totalCount: Int = 0, val ownerComment: CommentBean, val comments: List = emptyList(), ) ================================================ FILE: src/jvmMain/kotlin/model/LoginResult.kt ================================================ package model import androidx.annotation.Keep @Keep data class QrcodeKeyResult(val data: QrcodeKeyBean): BaseResult() @Keep data class QrcodeValueResult(val data: QrcodeValueBean): BaseResult() @Keep data class QrcodeKeyBean(val unikey: String) @Keep data class QrcodeValueBean(val qrurl: String, val qrimg: String?) @Keep data class QrcodeAuthResult(val cookie: String): BaseResult() { override fun resultOk(): Boolean { return code == 803 } } @Keep data class AccountInfoResult(val account: AccountBean, val profile: ProfileBean): BaseResult() @Keep data class LoginResult( val account: AccountBean, val profile: ProfileBean, val cookie: String ) @Keep data class AccountBean( val id: Long, val userName: String, val type: Int, val status: Int, val whitelistAuthority: Int, val createTime: Long, val tokenVersion: Int, val ban: Int, val baoyueVersion: Int, val donateVersion: Int, val vipType: Int, val viptypeVersion: Double, val anonimousUser: Boolean ) @Keep data class ProfileBean( val followed: Boolean, val userId: Int, val defaultAvatar: Boolean, val avatarUrl: String?, val nickname: String, val birthday: Long, val province: Int, val accountStatus: Int, val vipType: Int, val gender: Int, val djStatus: Int, val mutual: Boolean, val authStatus: Int, val backgroundImgId: Long, val userType: Int, val city: Int, val backgroundUrl: String?, val followeds: Int, val follows: Int, val eventCount: Int, val playlistCount: Int, val playlistBeSubscribedCount: Int ) ================================================ FILE: src/jvmMain/kotlin/model/LyricResult.kt ================================================ package model import androidx.annotation.Keep import model.BaseResult /** * Created by ssk on 2022/5/11. */ @Keep class LyricResult( val transUser: LyricContributorBean?, val lyricUser: LyricContributorBean?, val lrc: LrcBean?, val tlyric: LrcBean? ) : BaseResult() { override fun isEmpty() = transUser == null && lyricUser == null && lrc == null && tlyric == null } @Keep data class LyricContributorBean( val id: Long, val status: Int, val demand: Int, val userid: Long, val nickname: String, val uptime: Long ) /** * version : 11 * lyric : [00:00.42]遠く離れてるほどに 近くに感じてる * [00:07.87]寂しさも強さへと 変換(かわ)ってく * [00:13.74]…君を想ったなら * [00:34.98]街も 人も 夢も 変えていく時間に * [00:41.54]ただ 逆らっていた * [00:48.35]言葉を重ねても 理解(わか)り合えないこと * [00:56.18]まだ 知らなかったね * [01:01.76] * [01:03.20]君だけを抱きしめたくて失くした夢 君は * [01:10.55]「諦メナイデ」と云った * [01:16.48] * [01:17.63]遠く離れてるほどに 近くに感じてる * [01:24.38]寂しさも強さへと 変換(かわ)ってく * [01:29.66]…君を想ったなら * [01:32.75]切なく胸を刺す それは夢の欠片(かけら) * [01:39.06]ありのまま出逢えてた その奇跡 * [01:44.42]もう一度信じて * [01:47.85] * [01:54.90]君がいない日々に ずっと 立ち止まった * [02:02.73]でも 歩き出してる * [02:09.58]君と分かち合った どの偶然にも意味が * [02:17.50]そう 必ずあった * [02:23.18] * [02:24.53]それぞれの夢を叶えて まためぐり逢う時 * [02:31.85]偶然は運命になる * [02:37.46] * [02:38.91]破れた約束さえも 誓いに変えたなら * [02:45.55]あの場所で 出逢う時 あの頃の * [02:50.97]二人に戻(なれ)るかな? * [02:54.05]"優しさ" に似ている 懐かしい面影 * [03:00.50]瞳(め)を閉じて見えるから * [03:04.23]手を触れず在(あ)ることを知るから * [03:09.01] * [03:34.32]明日(あす)に はぐれて 答えが * [03:37.79]何も見えなくても * [03:41.10]君に逢う そのために重ねてく * [03:46.43]"今日" という真実 * [03:50.27] * [03:50.95]遠く離れてるほどに 近くに感じてる * [03:57.77]寂しさも強さへと 変換(かわ)ってく * [04:03.03]…君を想ったなら * [04:06.02]切なく胸を刺す それは夢の欠片(かけら) * [04:12.53]ありのまま出逢えてた その奇跡 * [04:17.75]もう一度信じて */ @Keep data class LrcBean( val version: Int, val lyric: String ) ================================================ FILE: src/jvmMain/kotlin/model/NewSongResult.kt ================================================ package model /** * 新歌速递 */ data class NewSongResult( var data: List ) : BaseResult() { override fun isEmpty() = data.isEmpty() } data class NewSongBean( val name: String, val artists: List, val album: Album ) data class ArtistBean( val id: Long, val name: String ) data class Album( val id: Long, val name: String, val picUrl: String ) ================================================ FILE: src/jvmMain/kotlin/model/PlayListResult.kt ================================================ package model import androidx.annotation.Keep import java.io.Serializable /** * 推荐歌单结果 */ data class RecommendPlayListResult( val result: List ) : BaseResult() { override fun isEmpty() = result.isEmpty() } /** * 推荐歌单列表item */ data class SimplePlayListItem( val id: Long, val name: String, val picUrl: String, val copywriter: String?, val trackNumberUpdateTime: Long, val playCount: Long, val trackCount: Int, val subscribedCount: Int, val shareCount: Int ) : Serializable @Keep data class PlaylistDetail( val tracks: List?, val trackIds: List?, val creator: Subscribers, val name: String = "", val coverImgUrl: String = "", val trackCount: Int = 0, val id: Long = 0, val playCount: Long = 0, val description: String?, val shareCount: Int, val commentCount: Int, val trackUpdateTime: Long, val subscribedCount: Int, val subscribers: List, val tags: List, ) : Serializable { fun convertToSimple() = SimplePlayListItem(id, name, coverImgUrl, description, trackUpdateTime, playCount, trackCount, subscribedCount, shareCount) } /** * 歌单结果 */ data class PlayListResult( val playlists: List, val total: Int, val more: Boolean ) : BaseResult(), IBasePagingBean { override fun getTotalCount() = total } @Keep data class Subscribers( val userId: Long, val avatarUrl: String, val nickname: String, val description: String? ) : Serializable @Keep data class Track( val name: String, val id: Long, val mv: Long, val ar: List, val al: Al, ) : Serializable @Keep data class TrackId( val id: Int = 0, val v: Int = 0, val alg: String? = null ) : Serializable @Keep data class Ar( val id: Long, val name: String, ) @Keep data class Al( val id: Long, val name: String, val picUrl: String, ) /** * 热门歌单分类 * */ data class HotPlayListTabResult(val tags: List) : BaseResult() data class PlayListTab( val id: Int, val name: String, val category: Int, val hot: Boolean, ) /** * 歌单分类 */ data class PlayListTabResult( val all: PlayListTab, val sub: List, val categories: Map) : BaseResult() @Keep data class PlaylistDetailResult( val playlist: PlaylistDetail, ) : BaseResult() /** * 个人歌单 */ @Keep data class UserPlaylistResult( val playlist: List, ) : BaseResult() ================================================ FILE: src/jvmMain/kotlin/model/PrivateContentResult.kt ================================================ package model /** * 独家放送 */ data class PrivateContentResult( val result: List ) : BaseResult() { override fun isEmpty() = result.isEmpty() } data class PrivateContentItem( val id: Long, val url: String?, val picUrl: String, val sPicUrl: String, val type: Int, val copywriter: String, val name: String, val alg: String ) ================================================ FILE: src/jvmMain/kotlin/model/RecommendMVResult.kt ================================================ package model /** * 推荐MV */ data class RecommendMVResult( val result: List ) : BaseResult() { override fun isEmpty() = result.isEmpty() } data class RecommendMVItem( val id: Long, val name: String, val artistName: String, val copywriter: String, val picUrl: String, val playCount: Long ) ================================================ FILE: src/jvmMain/kotlin/model/SongDetailResult.kt ================================================ package model import androidx.annotation.Keep @Keep data class SongDetailResult(val songs: List) : BaseResult() { override fun isEmpty() = songs.isEmpty() } @Keep data class SongBean( //歌曲id val id: Long, //歌曲名称 val name: String, val al: Al, val ar: List, val dt: Int, ) { fun getSongTimeLength() : String { val dtSecond = dt / 1000 val min = dtSecond / 60 val second = dtSecond - min * 60 val minStr = if (min < 10) "0$min" else min.toString() val secondStr = if (second < 10) "0$second" else second.toString() return "$minStr:$secondStr" } } @Keep data class SongUrlBean(val data: List) @Keep data class SongUrl(val url: String) ================================================ FILE: src/jvmMain/kotlin/router/NavGraph.kt ================================================ package router import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.google.gson.Gson import model.SimplePlayListItem import moe.tlaster.precompose.navigation.NavHost import moe.tlaster.precompose.navigation.Navigator import moe.tlaster.precompose.navigation.query import moe.tlaster.precompose.navigation.transition.NavTransition import ui.discovery.DiscoveryPage import ui.playlist.PlayListDetailPage import ui.setting.SettingPage import ui.todo.TodoPage object NCNavigatorManager { lateinit var navigator: Navigator } @Composable fun NavGraph() { val navigator = NCNavigatorManager.navigator NavHost( navigator = navigator, navTransition = remember { NavTransition( createTransition = fadeIn(), destroyTransition = fadeOut(), pauseTransition = fadeOut(), resumeTransition = fadeIn(), ) }, initialRoute = RouterUrls.DISCOVERY ) { scene(RouterUrls.DISCOVERY) { DiscoveryPage() } scene(RouterUrls.PODCAST) { TodoPage("播客") } scene(RouterUrls.PERSONAL_FM) { TodoPage("私人fm") } scene(RouterUrls.VIDEO) { TodoPage("视频") } scene(RouterUrls.FOLLOW) { TodoPage("关注") } scene(RouterUrls.FAVORITE_MUSIC) { TodoPage("我喜欢的音乐") } scene(RouterUrls.DOWNLOAD_MANAGER) { TodoPage("个性推荐") } scene(RouterUrls.RECENT_PLAYLIST) { TodoPage("最近播放") } scene(RouterUrls.MY_CLOUD_DISK) { TodoPage("我的音乐云盘") } scene(RouterUrls.MY_PODCAST) { TodoPage("我的播客") } scene(RouterUrls.MY_COLLECT) { TodoPage("我的收藏") } scene(RouterUrls.SETTING) { SettingPage() } scene("${RouterUrls.PLAY_LIST_DETAIL}") {backStackEntry -> val simplePlayListInfo = backStackEntry.query("simplePlayListInfo") val simplePlayListItem = Gson().fromJson(simplePlayListInfo, SimplePlayListItem::class.java) PlayListDetailPage(simplePlayListItem) } } } ================================================ FILE: src/jvmMain/kotlin/router/RouterUrls.kt ================================================ package router object RouterUrls { // 发现音乐 const val DISCOVERY = "discovery" // 播客 const val PODCAST = "podcast" // 私人fm const val PERSONAL_FM = "personalFm" // 视频 const val VIDEO = "video" // 关注 const val FOLLOW = "follow" // 我喜欢的音乐 const val FAVORITE_MUSIC = "favoriteMusic" // 下载管理 const val DOWNLOAD_MANAGER = "downloadManager" // 最近播放 const val RECENT_PLAYLIST = "recentPlaylist" // 我的音乐云盘 const val MY_CLOUD_DISK = "myCloudDisk" // 我的播客 const val MY_PODCAST = "myPodcast" // 我的收藏 const val MY_COLLECT = "myCollect" // 歌单详情 const val PLAY_LIST_DETAIL = "playListDetail" // 设置 const val SETTING = "setting" } ================================================ FILE: src/jvmMain/kotlin/ui/common/CommonImage.kt ================================================ package ui.common import androidx.compose.foundation.Image import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import org.succlz123.lib.imageloader.ImageAsyncImageFile import org.succlz123.lib.imageloader.ImageAsyncImageUrl import org.succlz123.lib.imageloader.ImageRes import org.succlz123.lib.imageloader.core.ImageCallback @Composable fun AsyncImage( modifier: Modifier, url: String?, placeHolderUrl: String? = "image/ic_disk_place_holder.webp", errorUrl: String? = "image/ic_disk_place_holder.webp", contentScale: ContentScale = ContentScale.Crop ) { val imgUrl = url ?: "image/ic_disk_place_holder.webp" if (imgUrl.startsWith("http")) { ImageAsyncImageUrl(url = imgUrl, imageCallback = ImageCallback(placeHolderView = { placeHolderUrl?.let { Image( painter = painterResource(placeHolderUrl), contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) } }, errorView = { errorUrl?.let { Image( painter = painterResource(errorUrl), contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) } }) { Image( painter = it, contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) }) } else if (imgUrl.startsWith("/") || imgUrl.contains(":\\")) { ImageAsyncImageFile(filePath = imgUrl, imageCallback = ImageCallback(placeHolderView = { placeHolderUrl?.let { Image( painter = painterResource(placeHolderUrl), contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) } }, errorView = { errorUrl?.let { Image( painter = painterResource(errorUrl), contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) } }) { Image( painter = it, contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) }) } else { ImageRes(resName = imgUrl, imageCallback = ImageCallback(placeHolderView = { placeHolderUrl?.let { Image( painter = painterResource(placeHolderUrl), contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) } }, errorView = { errorUrl?.let { Image( painter = painterResource(errorUrl), contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) } }) { Image( painter = it, contentDescription = imgUrl, modifier = modifier, contentScale = contentScale ) }) } } ================================================ FILE: src/jvmMain/kotlin/ui/common/CommonTabLayout.kt ================================================ package ui.common import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.TabRowDefaults.tabIndicatorOffset import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import ui.common.theme.AppColorsProvider @Composable fun CommonTabLayout( selectedIndex: Int = 0, tabTexts: List, backgroundColor: Color = AppColorsProvider.current.background, // 背景颜色 selectedTextColor: Color = AppColorsProvider.current.firstText, // 选中tab字体颜色 unselectedTextColor: Color = AppColorsProvider.current.secondText, // 未选中tab字体颜色 indicatorColor: Brush = Brush.horizontalGradient(listOf(AppColorsProvider.current.primary, AppColorsProvider.current.secondary)), // 指示器颜色 style: CommonTabLayoutStyle = CommonTabLayoutStyle(), onTabSelected: ((index: Int) -> Unit)? = null ) { if (style.isScrollable) { ScrollableTabRow( selectedTabIndex = selectedIndex, modifier = style.modifier, edgePadding = 0.dp, backgroundColor = backgroundColor, indicator = @Composable { tabPositions -> if (style.showIndicator) { if (style.customIndicator != null) { style.customIndicator.invoke(tabPositions[selectedIndex], selectedIndex) } else { Box( modifier = Modifier .tabIndicatorOffset(tabPositions[selectedIndex]) .fillMaxSize(), contentAlignment = Alignment.BottomCenter ) { Divider( modifier = Modifier .width(style.indicatorWidth) .padding(bottom = style.indicatorPaddingBottom) .background( brush = indicatorColor, shape = RoundedCornerShape(50) ), thickness = style.indicatorHeight, color = Color.Transparent ) } } } }, divider = @Composable { Divider(color = Color.Transparent) } ) { tabTexts.forEachIndexed { i, tabText -> var fontWeight = FontWeight.Normal if (selectedIndex == i) { if (style.selectedTextBold) { fontWeight = FontWeight.Bold } } else { if (style.unselectedTextBold) { fontWeight = FontWeight.Bold } } Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures( onTap = { onTabSelected?.invoke(i) } ) } .zIndex(1f), contentAlignment = Alignment.Center ) { Text( text = tabText, fontSize = if (selectedIndex == i) style.selectedTextSize else style.unselectedTextSize, fontWeight = fontWeight, color = if (selectedIndex == i) selectedTextColor else unselectedTextColor, textAlign = TextAlign.Center, ) } } } } else { TabRow( selectedTabIndex = selectedIndex, modifier = style.modifier, backgroundColor = backgroundColor, indicator = @Composable { tabPositions -> if (style.showIndicator) { if (style.customIndicator != null) { style.customIndicator.invoke(tabPositions[selectedIndex], selectedIndex) } else { Box( modifier = Modifier .tabIndicatorOffset(tabPositions[selectedIndex]) .fillMaxSize(), contentAlignment = Alignment.BottomCenter ) { Divider( modifier = Modifier .width(style.indicatorWidth) .padding(bottom = style.indicatorPaddingBottom) .background( brush = indicatorColor, shape = RoundedCornerShape(50) ), thickness = style.indicatorHeight, color = Color.Transparent ) } } } }, divider = @Composable { Divider(color = Color.Transparent) } ) { tabTexts.forEachIndexed { i, tabText -> var fontWeight = FontWeight.Normal if (selectedIndex == i) { if (style.selectedTextBold) { fontWeight = FontWeight.Bold } } else { if (style.unselectedTextBold) { fontWeight = FontWeight.Bold } } Box( modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures( onTap = { onTabSelected?.invoke(i) } ) } .zIndex(1f), contentAlignment = Alignment.Center ) { Text( text = tabText, fontSize = if (selectedIndex == i) style.selectedTextSize else style.unselectedTextSize, fontWeight = fontWeight, color = if (selectedIndex == i) selectedTextColor else unselectedTextColor, textAlign = TextAlign.Center ) } } } } } /** * 通用TabBar样式 */ data class CommonTabLayoutStyle( val modifier: Modifier = Modifier, // 修饰 val selectedTextSize: TextUnit = 14.sp, // 选中tab字体大小 val unselectedTextSize: TextUnit = 14.sp, // 未选中tab字体大小 val selectedTextBold: Boolean = true, // 选中tab字体加粗 val unselectedTextBold: Boolean = false, // 未选中tab字体加粗 val showIndicator: Boolean = false, // 是否显示指示器 val indicatorWidth: Dp = 50.dp, // 指示器宽度 val indicatorHeight: Dp = 3.dp, // 指示器高度 val indicatorPaddingBottom: Dp = 0.dp, // 指示器高度 val isScrollable: Boolean = true, // 是否可滑动 val customIndicator: @Composable ((selectedTabPosition: TabPosition, selectedPosition: Int) -> Unit)? = null // 自定义指示器 ) ================================================ FILE: src/jvmMain/kotlin/ui/common/CpnActionMore.kt ================================================ package ui.common import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.theme.AppColorsProvider @Composable fun CpnActionMore(title: String, onClickMore: (() -> Unit) ?= null) { Row( modifier = Modifier.fillMaxWidth().height(60.dp) .onClick { onClickMore?.invoke() }, verticalAlignment = Alignment.CenterVertically ) { Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = AppColorsProvider.current.firstText) Icon( painterResource("image/ic_more.webp"), contentDescription = "更多", modifier = Modifier.size(16.dp) ) } } ================================================ FILE: src/jvmMain/kotlin/ui/common/ExpandableText.kt ================================================ package ui.common import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import ui.common.theme.AppColorsProvider @Composable fun ExpandableText( modifier: Modifier = Modifier, defaultLine: Int = 2, text: String = "", color: Color = AppColorsProvider.current.secondText, fontSize: TextUnit = 12.sp, fontStyle: FontStyle? = null, fontWeight: FontWeight? = null, fontFamily: FontFamily? = null, ) { //如果小于 defaultLine var isLessDefaultLine by remember { mutableStateOf(false) } var expand by remember { mutableStateOf(false) } var subContent by remember(text) { mutableStateOf(text) } var measureLineCount by remember(text) { mutableStateOf(false) } val anim = remember { Animatable(0f) } val scope = rememberCoroutineScope() val content = remember(measureLineCount, expand, text) { if (expand) text else subContent } Row( modifier.animateContentSize() ) { Text( text = content, modifier = Modifier.padding(end = 10.dp).weight(1f), maxLines = if (expand) Int.MAX_VALUE else defaultLine, color = color, fontSize = fontSize, fontStyle = fontStyle, fontWeight = fontWeight, fontFamily = fontFamily, onTextLayout = { textLayoutResult -> if (textLayoutResult.lineCount <= defaultLine && !expand) { val hasVisualOverflow = textLayoutResult.hasVisualOverflow if (!measureLineCount) { isLessDefaultLine = !hasVisualOverflow } if (hasVisualOverflow) { val lastCharIndex = textLayoutResult.getLineEnd(defaultLine - 1, true) //截取 Less状态的内容 val substring = content.substring(0, lastCharIndex) subContent = substring.substring(0, substring.length) + "..." } } measureLineCount = true }, ) if (!isLessDefaultLine) { Icon( painter = painterResource("image/ic_triangle_up.webp"), contentDescription = null, modifier = Modifier.clip(RoundedCornerShape(50)).onClick { expand = !expand scope.launch { anim.animateTo(if (expand) 1f else 0f) } } .size(24.dp) .padding(6.dp) .rotate( anim.value * 180 ), tint = AppColorsProvider.current.secondText ) } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/ListToGridItems.kt ================================================ package ui.common import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier fun LazyListScope.ListToGridItems( data: List, columns: Int, itemContent: @Composable BoxScope.(index: Int, item: T) -> Unit ) { val rows = (data.size + columns - 1) / columns val groups = mutableListOf>() for (row in 0 until rows) { val group = mutableListOf() for (column in 0 until columns) { val originIndex = row * columns + column if (originIndex < data.size) { group.add(data[originIndex]) } } groups.add(group) } items(groups.size) { val group = groups[it] Row(verticalAlignment = Alignment.CenterVertically) { group.forEachIndexed { index, item -> val originIndex = index + it * columns Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { itemContent(originIndex, item) } } if (it == rows - 1 && group.size < columns) { Spacer(modifier = Modifier.weight(columns - group.size.toFloat())) } } } } @Composable fun ColumnScope.ListToGridItems( data: List, columns: Int, itemContent: @Composable BoxScope.(index: Int, item: T) -> Unit ) { val rows = (data.size + columns - 1) / columns val groups = mutableListOf>() for (row in 0 until rows) { val group = mutableListOf() for (column in 0 until columns) { val originIndex = row * columns + column if (originIndex < data.size) { group.add(data[originIndex]) } } groups.add(group) } for (it in 0 until groups.size) { val group = groups[it] Row(verticalAlignment = Alignment.CenterVertically) { group.forEachIndexed { index, item -> val originIndex = index + it * columns Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) { itemContent(originIndex, item) } } if (it == rows - 1 && group.size < columns) { Spacer(modifier = Modifier.weight(columns - group.size.toFloat())) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/LoadingComponent.kt ================================================ package ui.common import androidx.compose.animation.core.* import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import ui.common.theme.AppColorsProvider /** * Created by ssk on 2021/9/15. */ @Composable fun LoadingComponent( modifier: Modifier = Modifier, loading: Boolean = true, loadingWidth: Dp = 30.dp, loadingHeight: Dp = 25.dp, loadingRadius: Boolean = true, color: Color = AppColorsProvider.current.primary, contentAlignment: Alignment = Alignment.Center ) { val anim by remember { mutableStateOf(Animatable(0.4f)) } LaunchedEffect(loading) { if (loading) { anim.animateTo( targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 450, easing = LinearEasing), repeatMode = RepeatMode.Reverse ) ) } else { anim.stop() } } Box( modifier = modifier, contentAlignment = contentAlignment ) { Canvas( modifier = Modifier .width(loadingWidth) .height(loadingHeight) ) { val rectWidth = size.width / 7 val canvasHeight = size.height val rectHeight1 = if (loading) { canvasHeight * (0.75f - anim.value * 0.75f + 0.25f) } else { canvasHeight * 0.7f } drawRoundRect( color = color, cornerRadius = CornerRadius(if(loadingRadius) rectWidth / 2 else 0f), topLeft = Offset(0f, canvasHeight - rectHeight1), size = Size(rectWidth, rectHeight1) ) val rectHeight2 = if (loading) { canvasHeight * (anim.value * 0.65f + 0.2f) } else { canvasHeight * 0.52f } drawRoundRect( color = color, cornerRadius = CornerRadius(if(loadingRadius) rectWidth / 2 else 0f), topLeft = Offset(rectWidth * 2, canvasHeight - rectHeight2), size = Size(rectWidth, rectHeight2) ) val rectHeight3 = if (loading) { canvasHeight * (0.6f - anim.value * 0.6f + 0.4f) } else { canvasHeight * 0.43f } drawRoundRect( color = color, cornerRadius = CornerRadius(if(loadingRadius) rectWidth / 2 else 0f), topLeft = Offset(rectWidth * 4, canvasHeight - rectHeight3), size = Size(rectWidth, rectHeight3) ) val rectHeight4 = if (loading) { //canvasHeight * (0.8f - anim.value * 0.8f + 0.2f) canvasHeight * (anim.value * 0.45f + 0.3f) } else { canvasHeight * 0.48f } drawRoundRect( color = color, cornerRadius = CornerRadius(if(loadingRadius) rectWidth / 2 else 0f), topLeft = Offset(rectWidth * 6, canvasHeight - rectHeight4), size = Size(rectWidth, rectHeight4) ) } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/ModifierExt.kt ================================================ package ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color /** * Created by ssk on 2022/3/2. */ /** * 带水波纹点击事件 * [enableRipple]:是否支持水波纹效果 * [rippleColor]:水波纹颜色 * [onClick]:点击回调 */ @Composable fun Modifier.onClick(enableRipple: Boolean = false, rippleColor: Color = Color.Unspecified, onClick: () -> Unit) = this.clickable ( interactionSource = remember { MutableInteractionSource() }, indication = if (enableRipple) rememberRipple(color = rippleColor, bounded = true) else null ) { onClick() } ================================================ FILE: src/jvmMain/kotlin/ui/common/NoSuccessComponent.kt ================================================ package ui.common import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.theme.AppColorsProvider @Composable fun NoSuccessComponent( modifier: Modifier = Modifier.fillMaxWidth().heightIn(min = 320.dp), contentAlignment: Alignment = Alignment.Center, iconResId: String = "image/ic_empty.xml", message: String = "暂无数据", retryBlock: (() -> Unit)? = null, ) { Box( modifier = modifier .let { if (retryBlock != null) it.onClick { retryBlock.invoke() } else it }, contentAlignment = contentAlignment ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() ) { Icon( painterResource(iconResId), null, tint = AppColorsProvider.current.primary, modifier = Modifier.size(100.dp) ) if (!message.isEmpty()) { Text( "$message", fontSize = 14.sp, color = AppColorsProvider.current.thirdText, modifier = Modifier.padding(top = 20.dp) ) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/PaingFooterNumBar.kt ================================================ package ui.common import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.theme.AppColorsProvider /** * 分页页数footerBar */ @Composable fun PaingFooterNumBar(totalNum: Int, pageSize: Int, curPage: Int, onSelectedPageCallback: (curPage: Int) -> Unit) { val totalPage = remember { (totalNum + (pageSize - 1)) / pageSize } Row( modifier = Modifier.padding(vertical = 10.dp).fillMaxWidth().height(30.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { PaingFooterPreItem(curPage, onSelectedPageCallback) // 最多显示10个item if (totalPage <= 10) { for (pageIndex in 1..totalPage) { PaingFooterNumItem(pageIndex, curPage, onSelectedPageCallback) } } else { // 总页数大于10,需要按照一定的规则显示省略号 if (curPage <= 5) { //当前页码小于5,前8个正常显示,第9个显示省略号,第10个显示最后一个页码 for (pageIndex in 1..8) { PaingFooterNumItem(pageIndex, curPage, onSelectedPageCallback) } PaingFooterMoreItem() PaingFooterNumItem(totalPage, curPage, onSelectedPageCallback) } else if (curPage >= totalPage - 5) { //当前页码大于倒数第五个页码,后8个正常显示,第1个显示省略号,第1个显示第一个页码 PaingFooterNumItem(1, curPage, onSelectedPageCallback) PaingFooterMoreItem() for (pageIndex in totalPage - 8..totalPage) { PaingFooterNumItem(pageIndex, curPage, onSelectedPageCallback) } } else { // 否则第1个显示第一个页码,第2个显示省略号,第9个显示省略号,第10个显示最后一个页码,其余6个以当前页码为中心,显示相邻第6个页码 PaingFooterNumItem(1, curPage, onSelectedPageCallback) PaingFooterMoreItem() for (pageIndex in curPage - 2..curPage + 3) { PaingFooterNumItem(pageIndex, curPage, onSelectedPageCallback) } PaingFooterMoreItem() PaingFooterNumItem(totalPage, curPage, onSelectedPageCallback) } } PaingFooterNextItem(totalPage, curPage, onSelectedPageCallback) } } @Composable private fun PaingFooterNumItem(pageIndex: Int, curPage: Int, onSelectedPage: (curPage: Int) -> Unit) { Box( modifier = Modifier.padding(horizontal = 2.dp).height(28.dp).widthIn(min = 28.dp) .clip(RoundedCornerShape(2.dp)) .onClick { onSelectedPage.invoke(pageIndex) } .border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(2.dp)) .let { if (curPage == pageIndex) it.background( AppColorsProvider.current.primary, RoundedCornerShape(2.dp) ) else it }, contentAlignment = Alignment.Center ) { Text( pageIndex.toString(), color = if (curPage == pageIndex) Color.White else AppColorsProvider.current.secondText, fontSize = 12.sp, modifier = Modifier.padding(horizontal = 2.dp) ) } } @Composable private fun PaingFooterMoreItem() { Box( modifier = Modifier.padding(horizontal = 2.dp).size(28.dp) .border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(2.dp)), contentAlignment = Alignment.Center ) { Text( "...", color = AppColorsProvider.current.secondText, fontSize = 12.sp ) } } @Composable private fun PaingFooterPreItem(curPage: Int, onSelectedPage: (curPage: Int) -> Unit) { Box( modifier = Modifier.padding(horizontal = 2.dp).size(28.dp) .clip(RoundedCornerShape(2.dp)) .border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(2.dp)) .let { if (curPage > 1) it.onClick { onSelectedPage.invoke(curPage - 1) } else it }, contentAlignment = Alignment.Center ) { Icon( painterResource("image/ic_more.webp"), tint = if (curPage == 1) AppColorsProvider.current.divider else AppColorsProvider.current.secondIcon, contentDescription = "", modifier = Modifier.size(14.dp).rotate(180f) ) } } @Composable private fun PaingFooterNextItem(totalNum: Int, curPage: Int, onSelectedPage: (curPage: Int) -> Unit) { Box( modifier = Modifier.padding(horizontal = 2.dp).size(28.dp) .clip(RoundedCornerShape(2.dp)) .border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(2.dp)) .let { if (curPage < totalNum) it.onClick { onSelectedPage.invoke(curPage + 1) } else it }, contentAlignment = Alignment.Center ) { Icon( painterResource("image/ic_more.webp"), tint = if (curPage == totalNum) AppColorsProvider.current.divider else AppColorsProvider.current.secondIcon, contentDescription = "", modifier = Modifier.size(14.dp) ) } } ================================================ FILE: src/jvmMain/kotlin/ui/common/SeekBar.kt ================================================ package ui.common import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.PaintingStyle import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.input.pointer.* import androidx.compose.ui.unit.dp import ui.common.theme.AppColorsProvider /** * Created by ssk on 2022/4/23. */ private val progressPaint = Paint().apply { isAntiAlias = true style = PaintingStyle.Fill } private val circlePaint = Paint().apply { isAntiAlias = true style = PaintingStyle.Fill } var width = 0f var height = 0f @OptIn(ExperimentalComposeUiApi::class) @Composable fun SeekBar( progress: Float = 0f, enableSeek: Boolean = false, seeking: (Float) -> Unit = {}, seekTo: (Float) -> Unit = {}, smallRadius: Float = 8.dp.value, largeRadius: Float = 12.dp.value, progressHeight: Float = 4.dp.value, seekBarColor: Color = Color.LightGray.copy(0.3f), progressColor: Color = AppColorsProvider.current.primary, circleColor: Color = Color.LightGray, modifier: Modifier = Modifier ) { var isPressed by remember { mutableStateOf(false) } var circleCenterX by remember { mutableStateOf(0f) } circlePaint.color = circleColor Box( modifier = modifier .onPointerEvent(PointerEventType.Press) { if (enableSeek) { isPressed = true val x = it.changes.first().position.x seeking.invoke(x * 100f / width) } } .onPointerEvent(PointerEventType.Move) { if (isPressed) { val x = it.changes.first().position.x circleCenterX = x if (x < 0f) { circleCenterX = 0f } else if (x > width) { circleCenterX = width } else { circleCenterX = x } seeking.invoke(circleCenterX * 100f / width) } } .onPointerEvent(PointerEventType.Release) { if (enableSeek) { seekTo.invoke(circleCenterX * 100f / width) isPressed = false } }, contentAlignment = Alignment.Center ) { Canvas( modifier = Modifier .fillMaxWidth() ) { width = drawContext.size.width height = drawContext.size.height drawIntoCanvas { progressPaint.color = seekBarColor val seekBarRect = Rect( Offset(0f, (height - progressHeight) / 2), Offset(width, (height + progressHeight) / 2) ) it.drawRect(seekBarRect, progressPaint) progressPaint.color = progressColor val progressRect = Rect( Offset(0f, (height - progressHeight) / 2), Offset(width * progress / 100, (height + progressHeight) / 2) ) it.drawRect(progressRect, progressPaint) var x = width * progress / 100 val radius = if (isPressed) largeRadius else smallRadius if (x < radius) { x = radius } else if (x > width - radius) { x = width - radius } it.drawCircle( Offset(x, height / 2), radius, circlePaint ) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/TableLayout.kt ================================================ package ui.common import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout /** * Created by ssk on 2022/4/18. */ @Composable fun TableLayout( modifier: Modifier = Modifier, cellsCount: Int, content: @Composable () -> Unit ) { Layout( content = content, modifier = modifier, ) { measurables, constraints -> val parentWidth = constraints.maxWidth val cellWidth = parentWidth / cellsCount var totalHeight = 0 val cellsHeightPerRow = mutableListOf() val rowHeights = mutableListOf() val placeables = measurables.mapIndexed { index, measurable -> val newConstraints = constraints.copy(minWidth = cellWidth, maxWidth = cellWidth) val placeable = measurable.measure(newConstraints) val childWidth = placeable.width val childHeight = placeable.height cellsHeightPerRow.add(childHeight) if (cellsHeightPerRow.size == cellsCount || index == measurables.size - 1) { var maxChildHeight = 0 cellsHeightPerRow.forEach { if (it > maxChildHeight) maxChildHeight = it } totalHeight += maxChildHeight rowHeights.add(maxChildHeight) cellsHeightPerRow.clear() } placeable } layout(parentWidth, totalHeight) { placeables.forEachIndexed { index, placeable -> val column = index % cellsCount val row = index / cellsCount val positionX = cellWidth * column var positionY = 0 for (i in 0 until row) { positionY += rowHeights[i] } placeable.placeRelative(positionX, positionY) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/ViewStateComponent.kt ================================================ package ui.common import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.google.gson.JsonParseException import moe.tlaster.precompose.ui.viewModel import moe.tlaster.precompose.viewmodel.ViewModel import base.ViewState import base.ViewStateMutableStateFlow import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.UUID /** * Description->页面状态切换组件, 根据viewStateLiveData,自动切换各种状态页面 * @param modifier:页面布局修饰 * @param key: 用于维护ViewStateComponentViewModel中的flow,避免页面重建后,数据重新加载 * @param initFlow: 初始化数据流,使用场景:某页面有需要显示popup,popup的内容来自网络,而且进入页面后可以预加载数据,等到用户点击显示popup时,数据以及加载完成 * @param loadDataBlock:数据加载块 * @param viewStateComponentModifier: 状态页面修饰 * @param viewStateContentAlignment:状态页面居中方式 * @param customEmptyComponent:自定义加载中布局,没设置则使用默认加载中布局 * @param customEmptyComponent:自定义空布局,没设置则使用默认空布局 * @param customFailComponent:自定义失败布局,没设置则使用默认失败布局 * @param customErrorComponent:自定义错误布局,没设置则使用默认错误布局 * @param contentView:正常页面内容 */ @Composable fun ViewStateComponent( modifier: Modifier = Modifier, key: String = UUID.randomUUID().toString(), initFlow: ViewStateMutableStateFlow? = null, loadDataBlock: (() -> ViewStateMutableStateFlow), viewStateComponentModifier: Modifier = Modifier.fillMaxWidth().heightIn(min = 320.dp), viewStateContentAlignment: Alignment = Alignment.Center, customLoadingComponent: @Composable (() -> Unit)? = null, customEmptyComponent: @Composable (() -> Unit)? = null, customFailComponent: @Composable ((errorMessage: String?, loadDataBlock: () -> Unit) -> Unit)? = null, customErrorComponent: @Composable ((errorMessage: Pair, loadDataBlock: () -> Unit) -> Unit)? = null, contentView: @Composable BoxScope.(data: T) -> Unit ) { val vm = viewModel(listOf(key)) { ViewStateComponentViewModel() } val reloadFlag = vm.reloadFlag val flow = remember(reloadFlag, key) { if (reloadFlag == 0) { // first load data if (vm.flow == null) { vm.flow = initFlow ?: loadDataBlock.invoke() } } else { // retry load data when user trigger loadDataBlock vm.flow = loadDataBlock.invoke() } vm.flow!! } val viewState by flow.collectAsState() Box( modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { when (viewState) { is ViewState.Loading -> { if (customLoadingComponent != null) { customLoadingComponent.invoke() } else { LoadingComponent( modifier = viewStateComponentModifier, contentAlignment = viewStateContentAlignment ) } } is ViewState.Success -> { contentView((viewState as ViewState.Success).data!!) } is ViewState.Empty -> { if (customEmptyComponent != null) { customEmptyComponent.invoke() } else { NoSuccessComponent( contentAlignment = viewStateContentAlignment, modifier = viewStateComponentModifier, ) { vm.reload() } } } is ViewState.Fail -> { if (customFailComponent != null) { customFailComponent.invoke( "错误码:${(viewState as ViewState.Fail).errorCode};${(viewState as ViewState.Fail).errorMsg},点我重试", ) { vm.reload() } } else { NoSuccessComponent( modifier = viewStateComponentModifier, message = "错误码:${(viewState as ViewState.Fail).errorCode};${(viewState as ViewState.Fail).errorMsg},点我重试", contentAlignment = viewStateContentAlignment ) { vm.reload() } } } is ViewState.Error -> { if (customErrorComponent != null) { customErrorComponent.invoke( getErrorMessagePair((viewState as ViewState.Error).exception), ) { vm.reload() } } else { val errorMessagePair = getErrorMessagePair((viewState as ViewState.Error).exception) NoSuccessComponent( modifier = viewStateComponentModifier, message = errorMessagePair.first, iconResId = errorMessagePair.second, contentAlignment = viewStateContentAlignment, ) { vm.reload() } } } } } } class ViewStateComponentViewModel : ViewModel() { var flow: ViewStateMutableStateFlow? = null var reloadFlag by mutableStateOf(0) private set fun reload() { reloadFlag++ } } fun getErrorMessagePair(exception: Throwable): Pair { return when (exception) { is ConnectException, is UnknownHostException -> { Pair("网络连接失败", "image/ic_network_error.xml") } is SocketTimeoutException -> { Pair("网络连接超时", "image/ic_network_error.xml") } is JsonParseException -> { Pair("数据解析错误", "image/ic_network_error.xml") } else -> { Pair("未知错误", "image/ic_network_error.xml") } } } @Composable fun ViewState.handleSuccess(callback: @Composable (data: T) -> Unit) { if (this is ViewState.Success) { callback.invoke(this.data) } } ================================================ FILE: src/jvmMain/kotlin/ui/common/ViewStateLazyGridPagingComponent.kt ================================================ package ui.common import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import model.IBasePagingBean import moe.tlaster.precompose.ui.viewModel import moe.tlaster.precompose.viewmodel.ViewModel import ui.playlist.cpn.CommentListViewModel import base.ViewState import base.ViewStateMutableStateFlow import java.util.* @Composable fun ViewStateLazyGridPagingComponent( modifier: Modifier = Modifier, key: String = UUID.randomUUID().toString(), columns: Int, pageSize: Int = 20, loadDataBlock: (pageSize: Int, curPage: Int) -> ViewStateMutableStateFlow, contentPadding: PaddingValues = PaddingValues(0.dp), lazyGridState: LazyGridState = rememberLazyGridState(), scrollHeader: (@Composable () -> Unit)? = null, stickyHeader: (@Composable () -> Unit)? = null, viewStateComponentModifier: Modifier = Modifier.fillMaxWidth().heightIn(min = 320.dp), viewStateContentAlignment: Alignment = Alignment.Center, customLoadingComponent: @Composable (() -> Unit)? = null, customEmptyComponent: @Composable (() -> Unit)? = null, customFailComponent: @Composable ((errorMessage: String?, loadDataBlock: () -> Unit) -> Unit)? = null, customErrorComponent: @Composable ((errorMessage: Pair, loadDataBlock: () -> Unit) -> Unit)? = null, gridContent: LazyGridScope.(data: T) -> Unit, ) { var height by remember { mutableStateOf(0) } SubComposeLazyList( modifier = Modifier.onGloballyPositioned { height = it.size.height }, scrollHeader, stickyHeader ) { scrollHeaderHeight, stickyHeaderHeight -> val localDensity = LocalDensity.current // fix 当有scrollHeader和stickyHeader时,当列表显示滑动,并且通过stickyHeader切换数据源,造成列表滚动的bug val viewStateComponentModifier = remember(height) { if (height == 0) { viewStateComponentModifier } else { viewStateComponentModifier.height(height = ((height + scrollHeaderHeight + stickyHeaderHeight) / localDensity.density).dp) } } val vm = viewModel(listOf(key)) { ViewStateLazyListViewModel() } val reloadFlag = vm.reloadFlag val flow = remember(reloadFlag, key) { if (reloadFlag == 0) { // first load data if (vm.flow == null) { vm.flow = loadDataBlock.invoke(pageSize, vm.curPage.value) } } else { // retry load data when user trigger loadDataBlock vm.flow = loadDataBlock.invoke(pageSize, vm.curPage.value) } vm.flow!! } val viewState by flow.collectAsState() var showStickyHeader by remember { mutableStateOf(false) } val firstVisibleItemIndex by remember { derivedStateOf { lazyGridState.firstVisibleItemIndex } } LaunchedEffect(Unit) { snapshotFlow { firstVisibleItemIndex } .collect { firstVisibleItemIndex -> if (stickyHeader != null) { val minShowStickyIndex = if (scrollHeader == null) 0 else 1 showStickyHeader = firstVisibleItemIndex >= minShowStickyIndex } } } Box { LazyVerticalGrid( GridCells.Fixed(columns), modifier, lazyGridState, contentPadding ) { if (scrollHeader != null) { item(span = { GridItemSpan(columns) }) { scrollHeader() } } if (stickyHeader != null) { item(span = { GridItemSpan(columns) }) { stickyHeader() } } when (viewState) { is ViewState.Loading -> { item(span = { GridItemSpan(columns) }) { if (customLoadingComponent != null) { customLoadingComponent.invoke() } else { LoadingComponent( modifier = viewStateComponentModifier, contentAlignment = viewStateContentAlignment ) } } } is ViewState.Empty -> { item(span = { GridItemSpan(columns) }) { if (customEmptyComponent != null) { customEmptyComponent.invoke() } else { NoSuccessComponent( contentAlignment = viewStateContentAlignment, modifier = viewStateComponentModifier, ) { vm.reload() } } } } is ViewState.Fail -> { item(span = { GridItemSpan(columns) }) { if (customFailComponent != null) { customFailComponent.invoke( "错误码:${(viewState as ViewState.Fail).errorCode};${(viewState as ViewState.Fail).errorMsg},点我重试", ) { vm.reload() } } else { NoSuccessComponent( modifier = viewStateComponentModifier, message = "错误码:${(viewState as ViewState.Fail).errorCode};${(viewState as ViewState.Fail).errorMsg},点我重试", contentAlignment = viewStateContentAlignment ) { vm.reload() } } } } is ViewState.Error -> { item(span = { GridItemSpan(columns) }) { if (customErrorComponent != null) { customErrorComponent.invoke( getErrorMessagePair((viewState as ViewState.Error).exception), ) { vm.reload() } } else { val errorMessagePair = getErrorMessagePair((viewState as ViewState.Error).exception) NoSuccessComponent( modifier = viewStateComponentModifier, message = errorMessagePair.first, iconResId = errorMessagePair.second, contentAlignment = viewStateContentAlignment, ) { vm.reload() } } } } is ViewState.Success -> { val data = (viewState as ViewState.Success).data gridContent(data) // 底部分页组件 if (data.getTotalCount() > CommentListViewModel.pageSize) { item(span = { GridItemSpan(columns) }) { PaingFooterNumBar(data.getTotalCount(), pageSize, vm.curPage.value) { vm.curPage.value = it vm.reload() } } } } } } if (showStickyHeader) { Box( modifier = Modifier.padding( start = contentPadding.calculateLeftPadding(LayoutDirection.Ltr), end = contentPadding.calculateEndPadding(LayoutDirection.Ltr) ) ) { stickyHeader?.invoke() } } } } } class ViewStateLazyListViewModel : ViewModel() { var flow: ViewStateMutableStateFlow? = null var reloadFlag by mutableStateOf(0) private set var curPage = mutableStateOf(1) fun reload() { reloadFlag++ } } @Composable private fun SubComposeLazyList( modifier: Modifier, scrollHeader: (@Composable () -> Unit)? = null, stickyHeader: (@Composable () -> Unit)? = null, content: @Composable (scrollHeader: Float, stickyHeader: Float) -> Unit ) { SubcomposeLayout( modifier = modifier .clipToBounds() ) { constraints -> var scrollHeaderPlaceable: Placeable? = null scrollHeader?.let { scrollHeaderPlaceable = subcompose("scrollHeader", scrollHeader).first().measure(constraints) } var stickyHeaderPlaceable: Placeable? = null stickyHeader?.let { stickyHeaderPlaceable = subcompose("stickyHeader", stickyHeader).first().measure(constraints) } val contentPlaceable = subcompose("content") { content( scrollHeaderPlaceable?.height?.toFloat() ?: 0f, stickyHeaderPlaceable?.height?.toFloat() ?: 0f, ) }.map { it.measure(constraints) }.first() layout(contentPlaceable.width, contentPlaceable.height) { contentPlaceable.placeRelative(0, 0) } } } fun LazyListScope.handleListContent( viewState: ViewState?, reloadDataBlock: () -> Unit, viewStateComponentModifier: Modifier = Modifier.fillMaxWidth().heightIn(min = 320.dp), callback: LazyListScope.(data: T) -> Unit, ) { when (viewState) { is ViewState.Empty -> { item { NoSuccessComponent( modifier = viewStateComponentModifier, ) { reloadDataBlock.invoke() } } } is ViewState.Fail -> { item { NoSuccessComponent( modifier = viewStateComponentModifier, message = "错误码:${viewState.errorCode};${viewState.errorMsg},点我重试", ) { reloadDataBlock.invoke() } } } is ViewState.Error -> { item { val errorMessagePair = getErrorMessagePair(viewState.exception) NoSuccessComponent( modifier = viewStateComponentModifier, message = errorMessagePair.first, iconResId = errorMessagePair.second, ) { reloadDataBlock.invoke() } } } is ViewState.Success -> { val data = viewState.data callback(data) } else -> { item { LoadingComponent(viewStateComponentModifier) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/common/toast/Toast.kt ================================================ package ui.common.toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.theme.AppColorsProvider import java.util.concurrent.ConcurrentLinkedQueue object ToastManager { const val LENGTH_SHORT = 3000L const val LENGTH_LONG = 5000L var toastMessage by mutableStateOf(null) private val messageQueue = ConcurrentLinkedQueue() init { val loopThread = Thread { while (true) { Thread.sleep(50) if (!messageQueue.isEmpty()) { val nextToastMessage = messageQueue.poll() if (nextToastMessage != null) { toastMessage = nextToastMessage Thread.sleep(nextToastMessage.during) } } else { toastMessage = null } } } loopThread.start() } fun showToast(message: String?, during: Long = LENGTH_SHORT) { message?.let { msg -> if (messageQueue.size > 5) { messageQueue.clear() } messageQueue.add(ToastMessage(message, during)) } } } data class ToastMessage(val message: String, val during: Long) @Composable fun BoxScope.Toast() { ToastManager.toastMessage?.message?.let {msg -> Box( modifier = Modifier.padding(horizontal = 50.dp).align(BiasAlignment(0f, 0.75f)) .border(BorderStroke(1.dp, AppColorsProvider.current.divider), RoundedCornerShape(6.dp)) .background(AppColorsProvider.current.pure.copy(0.9f), RoundedCornerShape(6.dp)) .padding(horizontal = 20.dp, vertical = 8.dp), contentAlignment = Alignment.Center ) { Text(msg, color = AppColorsProvider.current.firstText, fontSize = 14.sp, textAlign = TextAlign.Center) } } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/DiscoveryPage.kt ================================================ package ui.discovery import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.height import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import base.MusicPlayController import moe.tlaster.precompose.ui.viewModel import moe.tlaster.precompose.viewmodel.ViewModel import router.NCNavigatorManager import ui.common.CommonTabLayout import ui.common.CommonTabLayoutStyle import ui.common.theme.AppColorsProvider import ui.discovery.cpn.CpnPersonalRecommendContainer import ui.discovery.cpn.CpnRecommendPlayList import ui.main.cpn.CommonTitleBar import ui.todo.TodoPage /** * 发现音乐页面 */ @Composable fun DiscoveryPage() { val tabs = remember { listOf("个性推荐", "歌单", "排行榜", "歌手", "最新音乐") } val viewModel = viewModel { DiscoveryPageViewModel() } Column { CommonTitleBar { CommonTabLayout( selectedIndex = viewModel.selectedIndex.value, tabTexts = tabs, backgroundColor = if (MusicPlayController.showMusicPlayDrawer) AppColorsProvider.current.pure else AppColorsProvider.current.topBarColor, style = CommonTabLayoutStyle(modifier = Modifier.height(50.dp)) ) { viewModel.selectedIndex.value = it } } when (viewModel.selectedIndex.value) { 0 -> CpnPersonalRecommendContainer(viewModel.selectedIndex) 1 -> CpnRecommendPlayList() else -> TodoPage(tabs[viewModel.selectedIndex.value], false) } } } class DiscoveryPageViewModel : ViewModel() { val selectedIndex = mutableStateOf(0) } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnHighQualityPlayListEntrance.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import http.NCRetrofitClient import model.PlayListResult import model.PlaylistDetail import moe.tlaster.precompose.ui.viewModel import ui.common.AsyncImage import ui.common.handleSuccess import ui.common.theme.AppColorsProvider import base.BaseViewModel import base.ViewStateMutableStateFlow /** * 个性推荐-精品歌单-入口 */ @Composable fun CpnHighQualityPlayListEntrance(tag: String) { val highQualityPlayListEntranceViewModel = viewModel { HighQualityPlayListEntranceViewModel() } val flow = remember(tag) { highQualityPlayListEntranceViewModel.getHighQualityPlayList(tag) } Box( modifier = Modifier.fillMaxWidth().height(200.dp) .border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(6.dp)) .background(AppColorsProvider.current.divider.copy(0.2f), RoundedCornerShape(6.dp)) ) { flow?.collectAsState()?.value?.handleSuccess { Content(it.playlists.getOrNull(0)) } } } @Composable private fun Content(playlistBean: PlaylistDetail?) { playlistBean?.let { Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(6.dp))) { AsyncImage( modifier = Modifier.fillMaxSize().blur(80.dp), playlistBean.coverImgUrl, ) Row( modifier = Modifier.fillMaxSize().padding(15.dp), verticalAlignment = Alignment.CenterVertically ) { AsyncImage( modifier = Modifier.padding(end = 20.dp).size(130.dp).clip(RoundedCornerShape(6.dp)), playlistBean.coverImgUrl ) Column { Row( modifier = Modifier.width(100.dp).height(30.dp) .border(BorderStroke(1.dp, color = Color(0xFFD8B839)), RoundedCornerShape(50)), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Icon( painterResource("image/ic_queue.webp"), modifier = Modifier.padding(end = 6.dp).size(16.dp), contentDescription = "", tint = Color(0xFFD8B839) ) Text("精品歌单", color = Color(0xFFD8B839), fontSize = 12.sp) } Text( playlistBean.name, color = AppColorsProvider.current.pure, fontSize = 14.sp, modifier = Modifier.padding(top = 16.dp) ) Text( playlistBean.description ?: "", color = AppColorsProvider.current.pure, fontSize = 12.sp, modifier = Modifier.padding(top = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis ) } } } } } class HighQualityPlayListEntranceViewModel : BaseViewModel() { var flow: ViewStateMutableStateFlow? = null var lastTag = "" fun getHighQualityPlayList(tag: String?): ViewStateMutableStateFlow? { if (lastTag != tag) { lastTag = tag ?: "" flow = launchFlow { println("----getHighQualityPlayList done, lastTag=${lastTag}") NCRetrofitClient.getNCApi().getHighQualityPlayList(1, tag) } } return flow } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnNewSongEntrance.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import http.NCRetrofitClient import model.NewSongBean import model.NewSongResult import ui.common.* import ui.common.theme.AppColorsProvider import base.BaseViewModel import base.ViewState import base.ViewStateMutableStateFlow /** * 个性推荐-最新音乐入口 */ fun LazyListScope.CpnNewSongEntrance(viewModel: NewSongEntranceViewModel, viewState: ViewState?) { item { CpnActionMore("最新音乐") } handleListContent(viewState, reloadDataBlock = { viewModel.getNewSong(false) }) { data -> ListToGridItems(data.data, 2) { index, item -> NewSongItem(index, item) } } } @Composable private fun NewSongItem(index: Int, item: NewSongBean) { Column(verticalArrangement = Arrangement.Center) { Divider( modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp), thickness = 0.5.dp, color = AppColorsProvider.current.divider ) Row( modifier = Modifier.padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically ) { Box { AsyncImage(modifier = Modifier.padding(vertical = 10.dp).size(72.dp).clip(RoundedCornerShape(6.dp)), item.album.picUrl) Icon( painter = painterResource("image/ic_logo_play.webp"), contentDescription = "", modifier = Modifier.size(28.dp).align(Alignment.Center), tint = Color.White ) } val num = if (index < 10) "0$index" else "$index" Text(num, color = AppColorsProvider.current.thirdText, fontSize = 12.sp, modifier = Modifier.padding(12.dp)) Column { Text( item.name, color = AppColorsProvider.current.firstText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( item.artists[0].name, color = AppColorsProvider.current.secondText, fontSize = 12.sp, modifier = Modifier.padding(top = 6.dp), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Divider( modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp), thickness = 0.5.dp, color = AppColorsProvider.current.divider ) } } class NewSongEntranceViewModel : BaseViewModel() { var flow by mutableStateOf?>(null) fun getNewSong(firstLoad: Boolean) { if (!firstLoad || flow == null) { flow = launchFlow(handleSuccessBlock = { it.data = it.data.take(10) }) { println("获取新歌速递...") NCRetrofitClient.getNCApi().getNewSong() } } } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnPersonalRecommend.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import moe.tlaster.precompose.ui.viewModel import base.BaseViewModel /** * 个性推荐 */ @Composable fun CpnPersonalRecommendContainer(recommendTagIndex: MutableState) { val personalRecommendViewModel = viewModel { PersonalRecommendViewModel() } val playListEntranceViewModel = viewModel { RecommendPlayListEntranceViewModel() } val privateContentViewModel = viewModel { PrivateContentViewModel() } val newSongViewModel = viewModel { NewSongEntranceViewModel() } val recommendMVEntranceViewModel = viewModel { RecommendMVEntranceViewModel() } LaunchedEffect(Unit) { playListEntranceViewModel.getRecommendPlayList(true) privateContentViewModel.getPrivateContent(true) newSongViewModel.getNewSong(true) recommendMVEntranceViewModel.getRecommendMV(true) } val playListEntranceViewState = playListEntranceViewModel.flow?.collectAsState()?.value val privateContentViewState = privateContentViewModel.flow?.collectAsState()?.value val newSongViewState = newSongViewModel.flow?.collectAsState()?.value val recommendMVViewState = recommendMVEntranceViewModel.flow?.collectAsState()?.value LazyColumn( modifier = Modifier.padding(horizontal = 20.dp), state = personalRecommendViewModel.getLazyListStateState(rememberLazyListState()) ) { // 推荐歌单 CpnRecommandPlayListEntrance(playListEntranceViewModel, playListEntranceViewState) { recommendTagIndex.value = 1 } // 独家放送 CpnPrivateContentEntrance(privateContentViewModel, privateContentViewState) // 最新音乐 CpnNewSongEntrance(newSongViewModel, newSongViewState) // 推荐MV CpnRecommendMVEntrance(recommendMVEntranceViewModel, recommendMVViewState) } } class PersonalRecommendViewModel : BaseViewModel() { private var lazyListState: LazyListState? = null fun getLazyListStateState(state: LazyListState): LazyListState { if (lazyListState == null) { lazyListState = state } return lazyListState!! } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnPlayListItem.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.Image import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.gson.Gson import model.PlaylistDetail import moe.tlaster.precompose.navigation.NavOptions import router.NCNavigatorManager import router.RouterUrls import ui.common.AsyncImage import ui.common.theme.AppColorsProvider import util.StringUtil /** * 歌单item组件 */ @OptIn(ExperimentalComposeUiApi::class) @Composable fun CpnPlayListItem(item: PlaylistDetail) { var focusState by remember { mutableStateOf(false) } val navigator = NCNavigatorManager.navigator Column( modifier = Modifier.onPointerEvent(PointerEventType.Enter) { focusState = true }.onPointerEvent(PointerEventType.Exit) { focusState = false }.onClick { val url = "${RouterUrls.PLAY_LIST_DETAIL}?simplePlayListInfo=${Gson().toJson(item.convertToSimple())}" navigator.navigate(url) }, horizontalAlignment = Alignment.CenterHorizontally ) { Box { AsyncImage(modifier = Modifier.size(172.dp).clip(RoundedCornerShape(6.dp)), item.coverImgUrl) Row( modifier = Modifier.padding(top = 6.dp, end = 6.dp).align(Alignment.TopEnd), verticalAlignment = Alignment.CenterVertically ) { Image( painter = painterResource("image/ic_play_count.webp"), contentDescription = "", modifier = Modifier.padding(end = 6.dp).size(12.dp) ) Text( StringUtil.friendlyNumber(item.playCount), color = Color.White, fontSize = 12.sp, ) } if (focusState) { Icon( painter = painterResource("image/ic_logo_play.webp"), contentDescription = "", modifier = Modifier.padding(bottom = 6.dp, end = 6.dp).size(32.dp).align(Alignment.BottomEnd), tint = Color.White ) } } Text( item.name, color = AppColorsProvider.current.firstText, fontSize = 12.sp, maxLines = 2, modifier = Modifier.padding(top = 10.dp, start = 16.dp, end = 16.dp).height(48.dp) ) } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnPlayListTabSelectedBar.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.TableLayout import http.NCRetrofitClient import model.PlayListTab import model.PlayListTabResult import moe.tlaster.precompose.ui.viewModel import ui.common.ViewStateComponent import ui.common.handleSuccess import ui.common.theme.AppColorsProvider import base.BaseViewModel import ui.common.onClick /** * 歌单详情-歌单标签tab组件 */ @Composable fun CpnPlayListTabSelectedBar() { val viewModel = viewModel { PlayListTabSelectedBarViewModel() } val showTabsPopup = remember { mutableStateOf(false) } Row(modifier = Modifier.background(AppColorsProvider.current.pure).padding(vertical = 16.dp).fillMaxWidth()) { PlayListTabToggle(showTabsPopup) HotPlayListTabs(viewModel) } TabsPopup(showTabsPopup) } @Composable private fun TabsPopup(showTabsPopup: MutableState) { CursorDropdownMenu( expanded = showTabsPopup.value, onDismissRequest = { showTabsPopup.value = false }, //offset = DpOffset(20.dp, 10.dp), ) { TabsPopupContent(showTabsPopup) } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun TabsPopupContent(showTabsPopup: MutableState) { val viewModel = viewModel { PlayListTabSelectedBarViewModel() } ViewStateComponent(modifier = Modifier.width(660.dp).height(320.dp), initFlow = viewModel.playListTabFlow, key = "TabsPopupContent", loadDataBlock = { viewModel.getPlayListCategories() }) { data -> val groupTabsMap = remember { viewModel.generateGroupTabsMap(data) } LazyColumn { stickyHeader { PlayListTabItem( modifier = Modifier.padding(start = 20.dp, bottom = 15.dp, top = 6.dp).height(32.dp), showTabsPopup, viewModel = viewModel, textSize = 13.sp, tag = data.all ) Divider(modifier = Modifier.fillMaxWidth(), thickness = 1.dp, color = AppColorsProvider.current.divider) } item { groupTabsMap.forEach { TabsPopupGroupTabsItem(showTabsPopup, viewModel, it.key, it.value) } } } } } @Composable private fun TabsPopupGroupTabsItem( showTabsPopup: MutableState, viewModel: PlayListTabSelectedBarViewModel, category: String, tabs: List ) { Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 10.dp)) { Text( category, modifier = Modifier.padding(end = 20.dp, top = 10.dp), color = AppColorsProvider.current.thirdText, fontSize = 12.sp ) TableLayout(cellsCount = 6, modifier = Modifier.fillMaxWidth()) { tabs.forEach { PlayListTabItem( modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp).height(30.dp), showTabsPopup, viewModel = viewModel, tag = it ) } } } } @Composable private fun PlayListTabToggle(showTabsPopup: MutableState) { val viewModel = viewModel { PlayListTabSelectedBarViewModel() } Row( modifier = Modifier.padding(end = 20.dp).width(110.dp).height(30.dp).clip(RoundedCornerShape(50)).onClick { showTabsPopup.value = true }.border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(50)), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( viewModel.selectedTab?.name ?: "选择标签", color = AppColorsProvider.current.firstIcon, fontSize = 14.sp ) Icon( painterResource("image/ic_more.webp"), modifier = Modifier.size(16.dp), contentDescription = "", tint = AppColorsProvider.current.firstIcon ) } } @Composable private fun PlayListTabItem( modifier: Modifier, showTabsPopup: MutableState, textSize: TextUnit = 12.sp, viewModel: PlayListTabSelectedBarViewModel, tag: PlayListTab ) { Box(modifier = Modifier.fillMaxWidth().background(AppColorsProvider.current.pure)) { Box(modifier = modifier.clip(RoundedCornerShape(50)).onClick { viewModel.selectedTab = tag showTabsPopup.value = false }.let { if (tag.name == viewModel.selectedTab?.name) { it.background(AppColorsProvider.current.primary.copy(0.2f)) } else { it }.padding(horizontal = 6.dp, vertical = 3.dp) }, contentAlignment = Alignment.Center ) { Row { Text( tag.name, color = if (tag.name == viewModel.selectedTab?.name) AppColorsProvider.current.primary else AppColorsProvider.current.firstText, fontSize = textSize, maxLines = 1, overflow = TextOverflow.Ellipsis ) if (tag.hot) { Icon( painterResource("image/ic_hot.webp"), contentDescription = null, modifier = Modifier.padding(start = 2.dp).size(14.dp), tint = AppColorsProvider.current.primary ) } } } } } @Composable private fun RowScope.HotPlayListTabs(viewModel: PlayListTabSelectedBarViewModel) { viewModel.hotTabFlow.collectAsState().value.handleSuccess { data -> Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { LazyRow { items(data.tags.size) { HotPlayListTabItem(viewModel, data.tags[it], it == data.tags.size - 1) } } } } } @Composable private fun HotPlayListTabItem(viewModel: PlayListTabSelectedBarViewModel, tag: PlayListTab, lastIndex: Boolean) { Row( modifier = Modifier.height(30.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(modifier = Modifier.clip(RoundedCornerShape(50)).onClick { viewModel.selectedTab = tag }.let { if (tag.name == viewModel.selectedTab?.name) { it.background(AppColorsProvider.current.primary.copy(0.2f)) } else { it }.padding(horizontal = 6.dp, vertical = 3.dp) }) { Text( tag.name, color = if (tag.name == viewModel.selectedTab?.name) AppColorsProvider.current.primary else AppColorsProvider.current.secondText, fontSize = 12.sp ) } if (!lastIndex) { Divider( modifier = Modifier.padding(horizontal = 6.dp).width(1.dp), thickness = 12.dp, color = AppColorsProvider.current.divider ) } } } class PlayListTabSelectedBarViewModel : BaseViewModel() { var selectedTab by mutableStateOf(null) val hotTabFlow by lazy { launchFlow { println("hotTabFlow done") NCRetrofitClient.getNCApi().getHotPlayListCategories() } } val playListTabFlow = getPlayListCategories() fun getPlayListCategories() = launchFlow(handleSuccessBlock = { if (selectedTab == null) { selectedTab = it.all } }) { println("getPlayListCategories done") NCRetrofitClient.getNCApi().getPlayListCategories() } fun generateGroupTabsMap(data: PlayListTabResult): Map> { val categories = data.categories val groupTabsMap = hashMapOf>() categories.forEach { groupTabsMap[it.value] = mutableListOf() } data.sub.forEach { tab -> val categoryId = tab.category val category = data.categories[categoryId] groupTabsMap[category]?.add(tab) } return groupTabsMap } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnPrivateContentEntrance.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import http.NCRetrofitClient import model.PrivateContentItem import model.PrivateContentResult import ui.common.* import ui.common.theme.AppColorsProvider import base.BaseViewModel import base.ViewState import base.ViewStateMutableStateFlow /** * 个性推荐-独家放送入口 */ fun LazyListScope.CpnPrivateContentEntrance(viewModel: PrivateContentViewModel, viewState: ViewState?) { item { CpnActionMore("独家放送") } handleListContent(viewState, reloadDataBlock = { viewModel.getPrivateContent(false) }) { data -> ListToGridItems(data.result, 4) { _, item -> PrivateContentItem(item) } } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun PrivateContentItem(item: PrivateContentItem) { var focusState by remember { mutableStateOf(false) } Column( modifier = Modifier.onPointerEvent(PointerEventType.Enter) { focusState = true }.onPointerEvent(PointerEventType.Exit) { focusState = false }, horizontalAlignment = Alignment.CenterHorizontally ) { Box { AsyncImage( modifier = Modifier.width(180.dp).height(100.dp).clip(RoundedCornerShape(6.dp)), item.picUrl ) Row(modifier = Modifier.padding(top = 6.dp, end = 6.dp).align(Alignment.TopEnd), verticalAlignment = Alignment.CenterVertically) { Image( painter = painterResource("image/ic_play_count.webp"), contentDescription = "", modifier = Modifier.padding(end = 6.dp).size(12.dp) ) } if (focusState) { Icon( painter = painterResource("image/ic_logo_play.webp"), contentDescription = "", modifier = Modifier.padding(top = 6.dp, start = 6.dp).size(32.dp), tint = Color.White ) } } Text( item.name, color = AppColorsProvider.current.firstText, fontSize = 12.sp, maxLines = 2, modifier = Modifier.padding(top = 10.dp, start = 16.dp, end = 16.dp).height(48.dp) ) } } class PrivateContentViewModel : BaseViewModel() { var flow by mutableStateOf?>(null) fun getPrivateContent(firstLoad: Boolean) { if (!firstLoad || flow == null) { flow = launchFlow { println("获取独家放送...") NCRetrofitClient.getNCApi().getPrivateContent() } } } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnRecommandPlayListEntrance.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.Image import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.google.gson.Gson import http.NCRetrofitClient import model.RecommendPlayListResult import model.SimplePlayListItem import router.NCNavigatorManager import router.RouterUrls import ui.common.* import ui.common.theme.AppColorsProvider import util.StringUtil import base.BaseViewModel import base.ViewState import base.ViewStateMutableStateFlow import moe.tlaster.precompose.navigation.NavOptions /** * 个性推荐-推荐歌单入口 */ fun LazyListScope.CpnRecommandPlayListEntrance(viewModel: RecommendPlayListEntranceViewModel, viewState: ViewState?, onClickMore: () -> Unit) { item { CpnActionMore("推荐歌单") { onClickMore.invoke() } } handleListContent(viewState, reloadDataBlock = { viewModel.getRecommendPlayList(false) }) { data -> ListToGridItems(data.result, 5) { _, item -> CpnPlayListItem(item) } } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun CpnPlayListItem(item: SimplePlayListItem) { var focusState by remember { mutableStateOf(false) } Column( modifier = Modifier.onPointerEvent(PointerEventType.Enter) { focusState = true }.onPointerEvent(PointerEventType.Exit) { focusState = false }.onClick { val url = "${RouterUrls.PLAY_LIST_DETAIL}?simplePlayListInfo=${Gson().toJson(item)}" println("navigate to PLAY_LIST_DETAIL, url=$url") NCNavigatorManager.navigator.navigate(url) }, horizontalAlignment = Alignment.CenterHorizontally ) { Box { AsyncImage( modifier = Modifier.size(140.dp).clip(RoundedCornerShape(6.dp)), item.picUrl ) Row( modifier = Modifier.padding(top = 6.dp, end = 6.dp).align(Alignment.TopEnd), verticalAlignment = Alignment.CenterVertically ) { Image( painter = painterResource("image/ic_play_count.webp"), contentDescription = "", modifier = Modifier.padding(end = 6.dp).size(12.dp) ) Text( StringUtil.friendlyNumber(item.playCount), color = Color.White, fontSize = 12.sp, ) } if (focusState) { Icon( painter = painterResource("image/ic_logo_play.webp"), contentDescription = "", modifier = Modifier.padding(bottom = 6.dp, end = 6.dp).size(32.dp).align(Alignment.BottomEnd), tint = Color.White ) } } Text( item.name, color = AppColorsProvider.current.firstText, fontSize = 12.sp, maxLines = 2, modifier = Modifier.padding(top = 10.dp, start = 16.dp, end = 16.dp).height(48.dp) ) } } class RecommendPlayListEntranceViewModel : BaseViewModel() { var flow by mutableStateOf?>(null) fun getRecommendPlayList(firstLoad: Boolean) { if (!firstLoad || flow == null) { flow = launchFlow { println("获取推荐歌单...") NCRetrofitClient.getNCApi().getRecommendPlayList(15) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnRecommendPlayList.kt ================================================ package ui.discovery.cpn import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import http.NCRetrofitClient import moe.tlaster.precompose.ui.viewModel import ui.common.ViewStateLazyGridPagingComponent import base.BaseViewModel /** * 推荐歌单-更多列表组件 */ @Composable fun CpnRecommendPlayList() { val playListTabSelectedBarViewModel = viewModel { PlayListTabSelectedBarViewModel() } val cpnRecommendPlayListViewModel = viewModel { RecommendPlayListViewModel() } val requestTag = playListTabSelectedBarViewModel.selectedTab?.name ?: "全部歌单" ViewStateLazyGridPagingComponent(modifier = Modifier.fillMaxSize(), key = "CpnRecommendPlayList-${requestTag}", columns = 4, pageSize = 40, contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp), viewStateContentAlignment = BiasAlignment(0f, -0.6f), loadDataBlock = { pageSize, cupage -> cpnRecommendPlayListViewModel.getPlayList(requestTag, pageSize, cupage) }, scrollHeader = { CpnHighQualityPlayListEntrance(requestTag) }, stickyHeader = { CpnPlayListTabSelectedBar() } ) { data -> items(data.playlists.size) { CpnPlayListItem(data.playlists[it]) } } } class RecommendPlayListViewModel : BaseViewModel() { fun getPlayList(tag: String, pageSize: Int, curPage: Int) = launchFlow { val offset = (curPage - 1) * pageSize println("CpnRecommendPlayListViewModel getPlayList tag=$tag,pageSize=$pageSize,offset=$offset") NCRetrofitClient.getNCApi().getPlayList(pageSize, tag, offset) } } ================================================ FILE: src/jvmMain/kotlin/ui/discovery/cpn/CpnRecommentMVEntrance.kt ================================================ package ui.discovery.cpn import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import http.NCRetrofitClient import model.RecommendMVItem import model.RecommendMVResult import ui.common.* import ui.common.theme.AppColorsProvider import util.StringUtil import base.BaseViewModel import base.ViewState import base.ViewStateMutableStateFlow /** * 个性推荐-推荐MV入口 */ fun LazyListScope.CpnRecommendMVEntrance(viewModel: RecommendMVEntranceViewModel, viewState: ViewState?) { item { CpnActionMore("推荐MV") } handleListContent(viewState, reloadDataBlock = { viewModel.getRecommendMV(false) }) { data -> ListToGridItems(data.result, 4) { _, item -> RecommendMVItem(item) } } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun RecommendMVItem(item: RecommendMVItem) { var focusState by remember { mutableStateOf(false) } Column(modifier = Modifier.onPointerEvent(PointerEventType.Enter) { focusState = true }.onPointerEvent(PointerEventType.Exit) { focusState = false }, horizontalAlignment = Alignment.CenterHorizontally ) { Box { AsyncImage( modifier = Modifier.width(180.dp).height(100.dp).clip(RoundedCornerShape(6.dp)), item.picUrl ) Row( modifier = Modifier.padding(top = 6.dp, end = 6.dp).align(Alignment.TopEnd), verticalAlignment = Alignment.CenterVertically ) { Image( painter = painterResource("image/ic_play_count.webp"), contentDescription = "", modifier = Modifier.padding(end = 6.dp).size(12.dp) ) Text( StringUtil.friendlyNumber(item.playCount), color = Color.White, fontSize = 12.sp, ) } Column { AnimatedVisibility( visible = focusState ) { Box( modifier = Modifier.width(180.dp).background( Brush.verticalGradient( listOf( Color.Black.copy(0.75f), Color.Black.copy(0.25f) ) ), shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp) ).padding(vertical = 6.dp, horizontal = 10.dp) ) { Text( item.copywriter, color = Color.White, fontSize = 12.sp, maxLines = 3, overflow = TextOverflow.Ellipsis ) } } } } Text( item.name, color = AppColorsProvider.current.firstText, fontSize = 12.sp, maxLines = 1, modifier = Modifier.padding(top = 6.dp, start = 16.dp, end = 16.dp) ) Text( item.artistName, color = AppColorsProvider.current.secondText, fontSize = 12.sp, maxLines = 1, modifier = Modifier.padding(top = 4.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) ) } } class RecommendMVEntranceViewModel : BaseViewModel() { var flow by mutableStateOf?>(null) fun getRecommendMV(firstLoad: Boolean) { if (!firstLoad || flow == null) { flow = launchFlow { println("获取推荐MV...") NCRetrofitClient.getNCApi().getRecommendMV() } } } } ================================================ FILE: src/jvmMain/kotlin/ui/login/QrcodeLoginDialog.kt ================================================ package ui.login import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.rememberDialogState import base.UserManager import com.google.gson.Gson import http.NCRetrofitClient import kotlinx.coroutines.Job import kotlinx.coroutines.delay import util.QrcodeUtil import kotlinx.coroutines.launch import model.LoginResult import moe.tlaster.precompose.ui.viewModel import ui.common.AsyncImage import ui.common.LoadingComponent import ui.common.NoSuccessComponent import ui.common.theme.AppColorsProvider import base.BaseViewModel import java.io.File /** * 二维码登录对话框 */ @Composable fun QrcodeLoginDialog(show: MutableState) { val viewModel: LoginViewModel = viewModel { LoginViewModel() } LaunchedEffect(show.value) { if (show.value) { viewModel.qrcodeAuth() } else { viewModel.cancelLastJob() } } Dialog( onCloseRequest = { show.value = false }, visible = show.value, state = rememberDialogState(size = DpSize(400.dp, 480.dp)), resizable = false, title = "" ) { if (show.value) { QrcodeLoginDialogContent(show) } } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun QrcodeLoginDialogContent(show: MutableState) { val viewModel: LoginViewModel = viewModel { LoginViewModel() } // LaunchedEffect(show.value) { // if (show.value) { // viewModel.qrcodeAuth() // } else { // viewModel.clear() // } // } LaunchedEffect(viewModel.qrcodeAuthStatus) { if (viewModel.qrcodeAuthStatus == 800) { // 二维码过期,重走认证流程 println("----二维码过期,重新生成") viewModel.qrcodeAuth() } } LaunchedEffect(viewModel.getAccountInfoSuccess) { if (viewModel.getAccountInfoSuccess == true) { show.value = false } else if (viewModel.getAccountInfoSuccess == false) { // 获取用户信息失败,重走认证流程 viewModel.qrcodeAuth() } } var focusState by remember { mutableStateOf(false) } val totalWidth = remember { 400.dp } val smallWidth = remember { 180.dp } val largeWidth = remember { 220.dp } val imageInitOffset = remember { (totalWidth - largeWidth) / 2 + largeWidth - smallWidth } val imageTargetOffset = remember { (totalWidth - smallWidth * 2) / 2 } val qrcodeInitOffset = remember { (totalWidth - largeWidth) / 2 } val qrcodeTargetOffset = remember { totalWidth - (totalWidth - smallWidth * 2) / 2 - smallWidth } val scope = rememberCoroutineScope() val offsetAnim = remember { Animatable(0f) } Column( modifier = Modifier.height(480.dp).background(AppColorsProvider.current.pure).padding(top = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( "扫码登录", textAlign = TextAlign.Center, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = AppColorsProvider.current.firstText ) Box(modifier = Modifier.width(totalWidth) .onPointerEvent(PointerEventType.Enter) { focusState = true scope.launch { offsetAnim.animateTo(1f, tween(600)) } } .onPointerEvent(PointerEventType.Exit) { focusState = false scope.launch { offsetAnim.animateTo(0f, tween(600)) } }, contentAlignment = Alignment.CenterStart ) { CpnScanTipImage( Modifier.width(smallWidth).height(260.dp).padding(12.dp) .scale(offsetAnim.value * 0.3f + 0.7f) .offset(imageInitOffset + (imageTargetOffset - imageInitOffset) * offsetAnim.value) .alpha(offsetAnim.value) ) val cpnScanQrcodeWidth = largeWidth - (largeWidth - smallWidth) * offsetAnim.value CpnScanQrcode( Modifier.width(cpnScanQrcodeWidth) .height(300.dp) .offset(qrcodeInitOffset + (qrcodeTargetOffset - qrcodeInitOffset) * offsetAnim.value) .padding(top = 20.dp), cpnScanQrcodeWidth ) } } } @Composable private fun CpnScanTipImage(modifier: Modifier) { Image(painterResource("image/ic_scan_code_tip.webp"), contentDescription = "扫描二维码提示", modifier = modifier) } @Composable private fun CpnScanQrcode(modifier: Modifier, qrcodeSize: Dp) { val viewModel: LoginViewModel = viewModel { LoginViewModel() } val tip = when (viewModel.qrcodeAuthStatus) { 801, 802 -> "请使用网易云音乐APP\n扫码登录" 803 -> "正在获取用户信息..." null -> "正在加载二维码" else -> "加载二维码出错" } Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally ) { if (viewModel.qrcodeAuthStatus == 801 || viewModel.qrcodeAuthStatus == 802) { AsyncImage( modifier = Modifier.size(qrcodeSize), viewModel.qrcodeFile?.absolutePath ?: "", placeHolderUrl = null, errorUrl = null ) } else { Box( modifier = Modifier.size(qrcodeSize), contentAlignment = Alignment.Center ) { if (viewModel.qrcodeAuthStatus == null || viewModel.qrcodeAuthStatus == 803) { LoadingComponent() } else { NoSuccessComponent(modifier = Modifier.wrapContentSize(), message = "") { viewModel.qrcodeAuth() } } } } Text( tip, textAlign = TextAlign.Center, fontSize = 14.sp, color = AppColorsProvider.current.firstText, modifier = Modifier.padding(top = 14.dp) ) } } class LoginViewModel : BaseViewModel() { var qrcodeAuthStatus by mutableStateOf(null) var qrcodeFile by mutableStateOf(null) var getAccountInfoSuccess by mutableStateOf(null) private var mLastQrcodeAuthJob: Job? = null private var mCookie = "" fun qrcodeAuth() { mLastQrcodeAuthJob?.let { it.cancel() } qrcodeAuthStatus = null mLastQrcodeAuthJob = launch( handleFailBlock = { code, msg -> println("----handleFailBlock") qrcodeAuthStatus = code } ) { println("----start getLoginQrcodeKey") val qrcodeKeyResult = NCRetrofitClient.getNCApi().getLoginQrcodeKey() println("----start getLoginQrcodeValue") val qrcodeValueResult = NCRetrofitClient.getNCApi().getLoginQrcodeValue(qrcodeKeyResult.data.unikey) println("----start createQrcodeFile") qrcodeFile = QrcodeUtil.createQrcodeFile( qrcodeValueResult.data.qrurl, 500, 500 ) println("----start checkQrcodeAuthStatus") var qrcodeAuthResult = NCRetrofitClient.getNCApi().checkQrcodeAuthStatus(qrcodeKeyResult.data.unikey) qrcodeAuthStatus = qrcodeAuthResult.code println("----qrcodeAuthStatus = $qrcodeAuthStatus") while (mLastQrcodeAuthJob?.isActive != false) { // 4s轮训一次登录授权状态 delay(4000) qrcodeAuthResult = NCRetrofitClient.getNCApi().checkQrcodeAuthStatus(qrcodeKeyResult.data.unikey) qrcodeAuthStatus = qrcodeAuthResult.code if (qrcodeAuthResult.resultOk()) { // 授权成功 println("----授权成功") mCookie = qrcodeAuthResult.cookie getAccountInfo() break } else if (qrcodeAuthStatus == 800) { println("----二维码过期") } else if (qrcodeAuthStatus == 801) { println("----等待扫码") } else if (qrcodeAuthStatus == 802) { println("----待确认") } } println("授权成功----请求个人信息和账户信息") qrcodeAuthResult } } private suspend fun getAccountInfo() { val accountInfoResult = NCRetrofitClient.getNCApi().getAccountInfo(mCookie) if (accountInfoResult.resultOk()) { val loginResult = LoginResult(accountInfoResult.account, accountInfoResult.profile, mCookie) UserManager.saveLoginResult(Gson().toJson(loginResult)) getAccountInfoSuccess = true } else { getAccountInfoSuccess = false } } fun cancelLastJob() { mLastQrcodeAuthJob?.cancel() } } ================================================ FILE: src/jvmMain/kotlin/ui/main/MainPage.kt ================================================ package ui.main import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import ui.common.toast.Toast import ui.main.cpn.CpnMusicPlayBottomBar import ui.main.cpn.CpnMainLeftMenu import ui.main.cpn.CpnMainMusicPlayDrawer import ui.main.cpn.CpnMainRightContainer import ui.play.CpnCurrentPlayListSheet /** * 主页 */ @Composable fun MainPage() { Column { Box(modifier = Modifier.weight(1f)) { Row(modifier = Modifier.fillMaxSize()) { CpnMainLeftMenu() CpnMainRightContainer() } CpnMainMusicPlayDrawer() CpnCurrentPlayListSheet() Toast() } CpnMusicPlayBottomBar() } } ================================================ FILE: src/jvmMain/kotlin/ui/main/cpn/CpnMainLeftMenu.kt ================================================ package ui.main.cpn import androidx.compose.animation.core.Animatable import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.AppConfig import base.MusicPlayController import base.UserManager import com.google.gson.Gson import http.NCRetrofitClient import kotlinx.coroutines.launch import model.PlaylistDetail import moe.tlaster.precompose.navigation.NavOptions import moe.tlaster.precompose.ui.viewModel import router.NCNavigatorManager import router.RouterUrls import ui.common.AsyncImage import ui.common.theme.AppColorsProvider import ui.login.QrcodeLoginDialog import base.BaseViewModel import ui.common.onClick /** * 主页左边菜单栏组件 */ @Composable fun CpnMainLeftMenu() { val navigator = NCNavigatorManager.navigator val loginResult = UserManager.getLoginResultFlow().collectAsState(null).value val viewModel: MainLeftMenuViewModel = viewModel { MainLeftMenuViewModel() } LaunchedEffect(loginResult) { if (loginResult != null) { viewModel.getUserPlayList(loginResult.account.id) } } Column(modifier = Modifier.width(200.dp).fillMaxHeight().background(AppColorsProvider.current.background)) { Spacer( modifier = Modifier.fillMaxWidth().height(AppConfig.topBarHeight) .background(if (MusicPlayController.showMusicPlayDrawer) AppColorsProvider.current.pure else AppColorsProvider.current.topBarColor) ) CpnUserInfo() Column( modifier = Modifier.verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { CpnMenuItem(viewModel, "image/ic_my_music.webp", "发现音乐") { while (navigator.canGoBack) { navigator.popBackStack() } navigator.navigate(RouterUrls.DISCOVERY, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_podcast.webp", "播客") { navigator.navigate(RouterUrls.PODCAST, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_fm.webp", "私人FM") { navigator.navigate(RouterUrls.PERSONAL_FM, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_video.webp", "视频") { navigator.navigate(RouterUrls.VIDEO, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_follows.webp", "关注") { navigator.navigate(RouterUrls.FOLLOW, NavOptions(launchSingleTop = true)) } CpnMyMusicTitle() viewModel.favoritePlayList?.let { CpnSongSheetItem(viewModel, "image/ic_like.webp", it) } CpnMenuItem(viewModel, "image/ic_download.webp", "下载管理") { navigator.navigate(RouterUrls.DOWNLOAD_MANAGER, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_recent_play_list.webp", "最近播放") { navigator.navigate(RouterUrls.RECENT_PLAYLIST, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_cloud.webp", "我的音乐云盘") { navigator.navigate(RouterUrls.MY_CLOUD_DISK, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_podcast.webp", "我的播客") { navigator.navigate(RouterUrls.MY_PODCAST, NavOptions(launchSingleTop = true)) } CpnMenuItem(viewModel, "image/ic_collect.webp", "我的收藏") { navigator.navigate(RouterUrls.MY_COLLECT, NavOptions(launchSingleTop = true)) } viewModel.selfCreatePlayList?.let { CpnSongSheet("创建的歌单", it) } viewModel.collectPlayList?.let { CpnSongSheet("收藏的歌单", it) } LogoutButton() } } } @Composable private fun CpnUserInfo() { val showLoginDialog = rememberSaveable { mutableStateOf(false) } val loginResult = UserManager.getLoginResultFlow().collectAsState(null).value Row( modifier = Modifier.fillMaxWidth().height(56.dp).onClick { if (loginResult == null) { showLoginDialog.value = true } }.padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically ) { AsyncImage( modifier = Modifier.clip(RoundedCornerShape(50)).size(36.dp), loginResult?.profile?.avatarUrl ?: "image/ic_default_avator.webp", "image/ic_default_avator.webp", "image/ic_default_avator.webp" ) Text( text = loginResult?.profile?.nickname ?: "未登录", modifier = Modifier.padding(horizontal = 10.dp), fontSize = 14.sp, maxLines = 1, color = AppColorsProvider.current.firstText, overflow = TextOverflow.Ellipsis ) Icon( painterResource("image/ic_triangle_right.webp"), contentDescription = "", modifier = Modifier.size(8.dp), tint = AppColorsProvider.current.firstIcon ) } QrcodeLoginDialog(showLoginDialog) } @Composable private fun CpnSongSheetItem(viewModel: MainLeftMenuViewModel, icon: String, playlistDetail: PlaylistDetail) { CpnMenuItem(viewModel, icon, playlistDetail.name, type = 1) { val url = "${RouterUrls.PLAY_LIST_DETAIL}?simplePlayListInfo=${Gson().toJson(playlistDetail.convertToSimple())}" println("navigate to PLAY_LIST_DETAIL, url=$url") NCNavigatorManager.navigator.navigate(url) } } /** * type:菜单类型,0表普通,1表歌单 */ @Composable private fun CpnMenuItem( viewModel: MainLeftMenuViewModel, logoPath: String, title: String, markLogoPath: String? = null, type: Int = 0, onClick: (title: Any) -> Unit ) { Row( modifier = Modifier.fillMaxWidth().height(40.dp).onClick { if (type == 0) { viewModel.selectedMenuTag = title viewModel.selectedSongSheetTag = null } else { viewModel.selectedMenuTag = null viewModel.selectedSongSheetTag = title } onClick(title) }.let { if (viewModel.selectedMenuTag == title || viewModel.selectedSongSheetTag == title) it.background( AppColorsProvider.current.pure ) else { it } }.padding(horizontal = 14.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( painterResource(logoPath), contentDescription = title, modifier = Modifier.size(18.dp), tint = AppColorsProvider.current.firstIcon ) Text( text = title, modifier = Modifier.weight(1f).padding(horizontal = 6.dp), fontSize = 12.sp, maxLines = 1, color = AppColorsProvider.current.firstText, overflow = TextOverflow.Ellipsis ) markLogoPath?.let { Icon(painterResource(it), contentDescription = null, modifier = Modifier.size(14.dp)) } } } @Composable private fun CpnMyMusicTitle() { Text( text = "我的音乐", modifier = Modifier.padding(top = 10.dp, bottom = 4.dp).fillMaxWidth().padding(horizontal = 16.dp), fontSize = 12.sp, maxLines = 1, color = AppColorsProvider.current.secondText, overflow = TextOverflow.Ellipsis ) } @Composable private fun ColumnScope.CpnSongSheet(title: String, list: List) { var expanded by remember { mutableStateOf(true) } val animValue = remember { Animatable(1f) } val scope = rememberCoroutineScope() val viewModel: MainLeftMenuViewModel = viewModel { MainLeftMenuViewModel() } Row( modifier = Modifier.onClick { expanded = !expanded scope.launch { animValue.animateTo(if (expanded) 1f else 0f) } }.padding(top = 16.dp, bottom = 8.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Icon( painterResource("image/ic_triangle_right.webp"), contentDescription = "", modifier = Modifier.padding(start = 8.dp).size(8.dp).rotate( 90 * animValue.value ), tint = AppColorsProvider.current.firstIcon ) Text( text = title, modifier = Modifier.weight(1f).padding(horizontal = 6.dp), fontSize = 12.sp, maxLines = 1, color = AppColorsProvider.current.secondText, overflow = TextOverflow.Ellipsis ) } Column(modifier = Modifier.fillMaxWidth().height((40f * animValue.value * list.size).dp)) { for (i in 0 until list.size) { CpnSongSheetItem(viewModel, "image/ic_song_sheet.webp", list[i]) } } } @Composable private fun LogoutButton() { val loginResult = UserManager.getLoginResultFlow().collectAsState(null).value if (loginResult != null) { val scope = rememberCoroutineScope() val viewModel = viewModel { MainLeftMenuViewModel() } Button(modifier = Modifier.padding(top = 60.dp, bottom = 24.dp).width(160.dp), colors = ButtonDefaults.buttonColors(backgroundColor = AppColorsProvider.current.primary), onClick = { scope.launch { UserManager.saveLoginResult("") viewModel.favoritePlayList = null viewModel.selfCreatePlayList = null viewModel.collectPlayList = null if (viewModel.selectedSongSheetTag != null) { viewModel.selectedSongSheetTag = null viewModel.selectedMenuTag = "发现音乐" while (NCNavigatorManager.navigator.canGoBack) { NCNavigatorManager.navigator.popBackStack() } NCNavigatorManager.navigator.navigate(RouterUrls.DISCOVERY, NavOptions(launchSingleTop = true)) } } }) { Text("退出登陆", color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium) } } } class MainLeftMenuViewModel : BaseViewModel() { var favoritePlayList: PlaylistDetail? by mutableStateOf(null) var selfCreatePlayList: List? by mutableStateOf(null) var collectPlayList: List? by mutableStateOf(null) var selectedMenuTag: String? by mutableStateOf("发现音乐") var selectedSongSheetTag: String? by mutableStateOf(null) fun getUserPlayList(userId: Long) { launch(handleSuccessBlock = { val selfCreateList = mutableListOf() val collectList = mutableListOf() it.playlist.forEach { PlaylistDetail -> if (PlaylistDetail.creator.userId == userId) { if (PlaylistDetail.name == PlaylistDetail.creator.nickname + "喜欢的音乐") { favoritePlayList = PlaylistDetail } else { selfCreateList.add(PlaylistDetail) } } else { collectList.add(PlaylistDetail) } } selfCreatePlayList = selfCreateList collectPlayList = collectList }) { NCRetrofitClient.getNCApi().getUserPlayList(userId.toString()) } } } ================================================ FILE: src/jvmMain/kotlin/ui/main/cpn/CpnMainMusicPlayDrawer.kt ================================================ package ui.main.cpn import androidx.compose.animation.* import androidx.compose.foundation.background import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import base.AppConfig import base.MusicPlayController import moe.tlaster.precompose.ui.viewModel import ui.common.theme.AppColorsProvider import ui.play.CpnLyric import ui.play.CpnMusicPlay import ui.play.CpnSongInfo import ui.playlist.cpn.CpnCommentList import ui.playlist.cpn.CommentListViewModel /** * 音乐播放抽屉组件 */ @Composable fun CpnMainMusicPlayDrawer() { Box { if (MusicPlayController.showMusicPlayDrawer) { Box( modifier = Modifier.height(AppConfig.topBarHeight).width(200.dp), ) { Icon( painterResource("image/ic_back.webp"), modifier = Modifier.padding(start = 72.dp, top = 4.dp).clip(RoundedCornerShape(50)) .background(AppColorsProvider.current.secondary.copy(0.05f)) .onClick { MusicPlayController.showMusicPlayDrawer = false }.padding(4.dp).size(12.dp).rotate(270f), contentDescription = "返回上一页", tint = AppColorsProvider.current.thirdIcon ) } } AnimatedVisibility( MusicPlayController.showMusicPlayDrawer, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { MusicPlayController.curSongBean?.let { songBean -> val commentViewModel = viewModel(keys = listOf(songBean.id)) { CommentListViewModel() } LaunchedEffect(songBean.id) { commentViewModel.fetchDataPaging("music", songBean.id, 1, true) } val commentViewState = commentViewModel.flow?.collectAsState() LazyColumn( modifier = Modifier.padding(top = AppConfig.topBarHeight).background(AppColorsProvider.current.pure).padding(horizontal = 30.dp) ) { item { Header() } CpnCommentList(commentViewState?.value, commentViewModel) { curPage -> commentViewModel.fetchDataPaging("music", songBean.id, curPage) } } } } } } @Composable private fun Header() { val height = remember { AppConfig.windowMinHeight - AppConfig.topBarHeight - 90.dp } Row(modifier = Modifier.fillMaxWidth().height(height)) { CpnMusicPlay(Modifier.weight(1f).fillMaxHeight()) Column(Modifier.padding(end = 80.dp).weight(1f).fillMaxHeight()) { CpnSongInfo() CpnLyric(Modifier) } } } ================================================ FILE: src/jvmMain/kotlin/ui/main/cpn/CpnMainRightContainer.kt ================================================ package ui.main.cpn import androidx.compose.foundation.background import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.onClick import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.AppConfig import base.MusicPlayController import router.NCNavigatorManager import router.NavGraph import router.RouterUrls import ui.common.theme.AppColorsProvider /** * 主页右边布局组件 */ @Composable fun CpnMainRightContainer() { Box(modifier = Modifier.fillMaxSize().background(color = AppColorsProvider.current.pure)) { Spacer( modifier = Modifier.fillMaxWidth().height(50.dp) .background(if (MusicPlayController.showMusicPlayDrawer) AppColorsProvider.current.pure else AppColorsProvider.current.topBarColor) ) NavGraph() CpnRightTopActionButtons() } } /** * 顶部TitleBar组件 */ @Composable fun CommonTitleBar( title: String = "", showBackButton: Boolean = false, customerContent: (@Composable () -> Unit)? = null ) { Box( modifier = Modifier.padding(end = 320.dp).fillMaxWidth().height(AppConfig.topBarHeight) .background(if (MusicPlayController.showMusicPlayDrawer) AppColorsProvider.current.pure else AppColorsProvider.current.topBarColor), contentAlignment = Alignment.CenterStart ) { if (!MusicPlayController.showMusicPlayDrawer) { if (customerContent != null) { customerContent.invoke() } else { Row(verticalAlignment = Alignment.CenterVertically) { if (showBackButton) { val navigator = NCNavigatorManager.navigator Icon( painterResource("image/ic_back.webp"), modifier = Modifier.padding(start = 20.dp).clip(RoundedCornerShape(50)).onClick { navigator.popBackStack() }.padding(4.dp).size(18.dp), contentDescription = "返回上一页", tint = AppColorsProvider.current.firstIcon ) } Text( title, color = AppColorsProvider.current.firstText, fontSize = 16.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 20.dp) ) } } } } } @Composable private fun BoxScope.CpnRightTopActionButtons() { val showPopupWindow = remember { mutableStateOf(false) } Row( modifier = Modifier.height(50.dp).width(320.dp).align(Alignment.TopEnd), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { Icon( painterResource("image/ic_setting.webp"), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(24.dp).padding(3.dp).onClick { NCNavigatorManager.navigator.navigate(RouterUrls.SETTING) }, tint = AppColorsProvider.current.firstIcon ) Icon( painterResource("image/ic_message.webp"), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(24.dp).padding(3.dp), tint = AppColorsProvider.current.firstIcon ) Icon( painterResource("image/ic_theme.webp"), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(24.dp).padding(3.dp).onClick { showPopupWindow.value = true }, tint = AppColorsProvider.current.firstIcon ) Icon( painterResource("image/ic_screen_min.webp"), contentDescription = null, modifier = Modifier.padding(end = 24.dp).size(24.dp).padding(3.dp), tint = AppColorsProvider.current.firstIcon ) } CpnThemePopup(showPopupWindow) } ================================================ FILE: src/jvmMain/kotlin/ui/main/cpn/CpnMusicPlayBottomBar.kt ================================================ package ui.main.cpn import ui.common.SeekBar import androidx.compose.foundation.background import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.MusicPlayController import base.player.PlayMode import ui.common.AsyncImage import ui.common.theme.AppColorsProvider /** * 主页底部音乐播放组件 */ @Composable fun CpnMusicPlayBottomBar() { Column(modifier = Modifier.background(color = AppColorsProvider.current.pure).fillMaxWidth().height(80.dp)) { CpnSeekBar() Row( modifier = Modifier.padding(horizontal = 20.dp).fillMaxSize(), verticalAlignment = Alignment.CenterVertically ) { CpnMusicInfo() CpnMiddleActionButtons() CpnRightActionButtons() } } } @Composable private fun CpnSeekBar() { SeekBar( progress = MusicPlayController.progress, enableSeek = MusicPlayController.enableSeeking, seeking = { MusicPlayController.seeking(it) }, seekTo = { MusicPlayController.seekTo(it) }, modifier = Modifier .fillMaxWidth() .height(10.dp) ) } @Composable private fun RowScope.CpnMusicInfo() { Row( modifier = Modifier.weight(1.5f).onClick { MusicPlayController.showMusicPlayDrawer = !MusicPlayController.showMusicPlayDrawer }, verticalAlignment = Alignment.CenterVertically ) { MusicPlayController.curSongBean?.let { curSong -> AsyncImage( Modifier.padding(end = 10.dp).size(48.dp).clip(RoundedCornerShape(4.dp)), url = curSong.al.picUrl ) Column { Row { Text( curSong.name, fontSize = 14.sp, color = AppColorsProvider.current.firstText, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( " - ${curSong.ar.getOrNull(0)?.name ?: "未知歌手"}", fontSize = 14.sp, color = AppColorsProvider.current.secondText, maxLines = 1, overflow = TextOverflow.Ellipsis ) } Text( "${MusicPlayController.curPositionStr} / ${curSong.getSongTimeLength()}", fontSize = 12.sp, color = AppColorsProvider.current.secondText, modifier = Modifier.padding(top = 6.dp) ) } } } } @Composable private fun RowScope.CpnMiddleActionButtons() { Row( modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Icon( painterResource("image/ic_like.webp"), contentDescription = null, modifier = Modifier.padding(end = 30.dp).size(20.dp).padding(2.dp), tint = AppColorsProvider.current.firstIcon ) Icon( painterResource("image/ic_action_pre.webp"), contentDescription = null, modifier = Modifier.padding(end = 20.dp).size(20.dp).padding(2.dp).onClick { val newIndex = MusicPlayController.getPreRealIndex() MusicPlayController.playByRealIndex(newIndex) }, tint = AppColorsProvider.current.primary ) Icon( painterResource(if (MusicPlayController.isPlaying()) "image/ic_action_pause.webp" else "image/ic_action_play.webp"), contentDescription = null, modifier = Modifier.padding(end = 20.dp).size(40.dp).padding(2.dp).onClick { if (MusicPlayController.isPlaying()) { MusicPlayController.pause() } else { MusicPlayController.resume() } }, tint = AppColorsProvider.current.primary ) Icon( painterResource("image/ic_action_next.webp"), contentDescription = null, modifier = Modifier.padding(end = 30.dp).size(20.dp).padding(2.dp).onClick { val newIndex = MusicPlayController.getNextRealIndex() MusicPlayController.playByRealIndex(newIndex) }, tint = AppColorsProvider.current.primary ) Icon( painterResource("image/ic_share.webp"), contentDescription = null, modifier = Modifier.size(20.dp).padding(2.dp), tint = AppColorsProvider.current.firstIcon ) } } @Composable private fun RowScope.CpnRightActionButtons() { Row(modifier = Modifier.weight(1.5f), horizontalArrangement = Arrangement.End) { Icon( painterResource("image/ic_sound_effect.webp"), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(20.dp).padding(2.dp), tint = AppColorsProvider.current.firstIcon ) val playModeResId = when (MusicPlayController.playMode) { PlayMode.RANDOM -> "image/ic_play_mode_random.webp" PlayMode.SINGLE -> "image/ic_play_mode_single.webp" PlayMode.LOOP -> "image/ic_play_mode_loop.webp" } Icon( painterResource(playModeResId), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(20.dp).padding(2.dp).onClick { when (MusicPlayController.playMode) { PlayMode.RANDOM -> MusicPlayController.changePlayMode(PlayMode.SINGLE) PlayMode.SINGLE -> MusicPlayController.changePlayMode(PlayMode.LOOP) PlayMode.LOOP -> MusicPlayController.changePlayMode(PlayMode.RANDOM) } }, tint = AppColorsProvider.current.firstIcon ) Icon( painterResource("image/ic_play_list.webp"), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(20.dp).padding(2.dp).onClick { MusicPlayController.showCurPlayListSheet = !MusicPlayController.showCurPlayListSheet }, tint = AppColorsProvider.current.firstIcon ) Icon( painterResource("image/ic_song_words.webp"), contentDescription = null, modifier = Modifier.padding(end = 14.dp).size(20.dp).padding(2.dp), tint = AppColorsProvider.current.primary ) Icon( painterResource("image/ic_volumn.webp"), contentDescription = null, modifier = Modifier.size(20.dp).padding(2.dp), tint = AppColorsProvider.current.firstIcon ) } } ================================================ FILE: src/jvmMain/kotlin/ui/main/cpn/CpnPlaformDecoratedButtons.kt ================================================ package ui.main.cpn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowState import base.AppConfig import ui.common.onClick import util.EnvUtil import kotlin.system.exitProcess /** * 自定义windows平台关闭、最小化、全屏按钮 */ @OptIn(ExperimentalComposeUiApi::class) @Composable fun ApplicationScope.CpnWindowsPlaformDecoratedButtons(windowState: WindowState) { // LaunchedEffect(windowState.placement) { // if (windowState.placement == WindowPlacement.Maximized) { // windowState.placement = WindowPlacement.Fullscreen // println("Fullscreen done...") // } // } if (EnvUtil.isWindows()) { var focusState by remember { mutableStateOf(false) } Row(modifier = Modifier.padding(start = 8.dp, top = 8.dp) .onPointerEvent(PointerEventType.Enter) { focusState = true }.onPointerEvent(PointerEventType.Exit) { focusState = false }) { ActionButton("image/ic_window_close.webp", Color(0xFFFC615D), focusState) { ::exitApplication exitProcess(0) } ActionButton("image/ic_window_min.webp", Color(0xFFFDBC40), focusState) { windowState.isMinimized = true } val imgUrl = if (AppConfig.fullScreen) { "image/ic_window_floating.webp" } else { "image/ic_window_fullscreen.webp" } ActionButton(imgUrl, Color(0xFF35CA4A), focusState) { if (AppConfig.fullScreen) { windowState.placement = WindowPlacement.Floating } else { windowState.placement = WindowPlacement.Maximized } AppConfig.fullScreen = !AppConfig.fullScreen } } } } @Composable private fun ActionButton(img: String, backgroundColor: Color, focusState: Boolean, onClick: () -> Unit) { Box( modifier = Modifier.padding(end = 8.dp).clip(CircleShape) .size(13.dp).background(backgroundColor).onClick { onClick() }, contentAlignment = Alignment.Center ) { if (focusState) { Icon(painterResource(img), modifier = Modifier.size(10.dp), contentDescription = null) } } } ================================================ FILE: src/jvmMain/kotlin/ui/main/cpn/CpnThemePopup.kt ================================================ package ui.main.cpn import androidx.compose.foundation.background import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.CursorDropdownMenu import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.theme.* import ui.common.theme.color.light.* /** * app主题色切换popup */ @Composable fun CpnThemePopup(showPopupWindow: MutableState) { val themeModels = remember { mutableStateListOf( mutableStateOf(ThemeModel("默认", THEME_DEFAULT, DefaultColorPalette.primary, true)), mutableStateOf(ThemeModel("夜间", THEME_NIGHT, DarkColorPalette.pure, false)), mutableStateOf(ThemeModel("蓝色", THEME_BLUE, BlueColorPalette.primary, false)), mutableStateOf(ThemeModel("绿色", THEME_GREEN, GreenColorPalette.primary, false)), mutableStateOf(ThemeModel("橙色", THEME_ORIGIN, OriginColorPalette.primary, false)), mutableStateOf(ThemeModel("紫色", THEME_PURPLE, PurpleColorPalette.primary, false)), mutableStateOf(ThemeModel("黄色", THEME_YELLOW, YellowColorPalette.primary, false)), ) } CursorDropdownMenu( showPopupWindow.value, onDismissRequest = { showPopupWindow.value = false }, ) { themeModels.forEachIndexed { index, themeModel -> Row( modifier = Modifier .fillMaxWidth() .height(36.dp) .onClick { themeTypeState.value = themeModel.value.themeType themeModels[lastSelectedThemeIndex].value = themeModels[lastSelectedThemeIndex].value.copy(selected = false) lastSelectedThemeIndex = index themeModels[index].value = themeModels[index].value.copy(selected = true) } .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(16.dp) .clip(RoundedCornerShape(50)) .background(themeModel.value.color) ) Text( text = themeModel.value.name, modifier = Modifier.padding(start = 12.dp, end = 20.dp), fontSize = 14.sp, color = AppColorsProvider.current.secondText ) if (themeModels[index].value.selected) { Icon( painterResource("image/ic_checked.webp"), contentDescription = null, modifier = Modifier.size(18.dp), tint = themeModel.value.color ) } } } } } private var lastSelectedThemeIndex = 0 data class ThemeModel(val name: String, val themeType: Int, val color: Color, val selected: Boolean) ================================================ FILE: src/jvmMain/kotlin/ui/play/CpnCurrentPlayListSheet.kt ================================================ package ui.play import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.AppConfig import base.MusicPlayController import ui.common.onClick import ui.common.theme.AppColorsProvider /** * 当前播放列表组件 */ @OptIn(ExperimentalMaterialApi::class) @Composable fun CpnCurrentPlayListSheet() { val sheetState = rememberModalBottomSheetState( ModalBottomSheetValue.Hidden, animationSpec = tween(durationMillis = 300), skipHalfExpanded = true, ) LaunchedEffect(MusicPlayController.showCurPlayListSheet) { if (MusicPlayController.showCurPlayListSheet) { sheetState.show() } else { sheetState.hide() } } ModalBottomSheetLayout( sheetContent = { Column { Spacer(modifier = Modifier.fillMaxWidth().height(AppConfig.topBarHeight).onClick { MusicPlayController.showCurPlayListSheet = false }) CpnCurrentPlayList { MusicPlayController.showCurPlayListSheet = false } } }, sheetState = sheetState, sheetElevation = 0.dp, sheetContentColor = Color.Transparent, sheetBackgroundColor = Color.Transparent, scrimColor = Color.Transparent ) { } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun CpnCurrentPlayList(hideCallback: () -> Unit) { val lazyListState = rememberLazyListState() LaunchedEffect(MusicPlayController.showCurPlayListSheet) { if (MusicPlayController.showCurPlayListSheet && MusicPlayController.curRealIndex >= 0 && MusicPlayController.curRealIndex < MusicPlayController.realSongList.size) { lazyListState.animateScrollToItem(MusicPlayController.curRealIndex, -36.dp.value.toInt()) } } Row(modifier = Modifier.fillMaxWidth()) { Spacer(modifier = Modifier.weight(1f).fillMaxHeight().onClick { hideCallback.invoke() }) Box( modifier = Modifier .width(3.dp) .fillMaxHeight() .background( brush = Brush.horizontalGradient(listOf(Color(0x00FFFFFF), AppColorsProvider.current.background)) ) ) LazyColumn( modifier = Modifier.width(420.dp).fillMaxHeight().background(AppColorsProvider.current.pure), state = lazyListState ) { stickyHeader { Row( modifier = Modifier.background(AppColorsProvider.current.pure) .padding(vertical = 15.dp, horizontal = 20.dp).fillMaxWidth(), verticalAlignment = Alignment.Bottom ) { Text( "播放列表", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = AppColorsProvider.current.firstText ) Text( " (共${MusicPlayController.realSongList.size}首)", fontSize = 14.sp, color = AppColorsProvider.current.secondText, ) } Divider( modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), thickness = 0.5.dp, color = AppColorsProvider.current.divider ) } items(MusicPlayController.realSongList.size) { SongItem(it) } } } } @Composable private fun SongItem(index: Int) { val curSongBean = MusicPlayController.realSongList[index] Row( modifier = Modifier .onClick { MusicPlayController.playByRealIndex(index) } .background( if (index % 2 == 0) Color.Transparent else AppColorsProvider.current.divider.copy( 0.25f ) ).height(36.dp).fillMaxWidth().padding(horizontal = 15.dp), verticalAlignment = Alignment.CenterVertically ) { Row(modifier = Modifier.weight(2f).fillMaxHeight(), verticalAlignment = Alignment.CenterVertically) { val num = if (index + 1 < 10) "0${index + 1}" else (index + 1).toString() if (MusicPlayController.curSongBean?.id != curSongBean.id) { Text( text = num, color = AppColorsProvider.current.thirdText, fontSize = 12.sp, modifier = Modifier.width(40.dp), textAlign = TextAlign.Center ) } else { Icon( painterResource("image/ic_playing.webp"), contentDescription = null, modifier = Modifier.width(40.dp).height(36.dp).padding(horizontal = 14.dp, vertical = 12.dp), tint = AppColorsProvider.current.primary ) } Text( text = curSongBean.name, color = AppColorsProvider.current.firstText, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, textAlign = TextAlign.Center ) } Text( modifier = Modifier.padding(start = 10.dp).weight(1f), text = curSongBean.ar.getOrNull(0)?.name ?: "未知歌手", color = AppColorsProvider.current.secondText, fontSize = 12.sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.weight(1f), text = curSongBean.getSongTimeLength(), color = AppColorsProvider.current.secondText, fontSize = 12.sp, textAlign = TextAlign.Center ) } } ================================================ FILE: src/jvmMain/kotlin/ui/play/CpnLyric.kt ================================================ package ui.play import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.MusicPlayController import base.player.IPlayerListener import base.player.PlayerStatus import http.NCRetrofitClient import model.LyricContributorBean import model.LyricResult import moe.tlaster.precompose.ui.viewModel import ui.common.ViewStateComponent import ui.common.theme.AppColorsProvider import util.LyricUtil import util.convertDp import base.BaseViewModel /** * 歌词组件 */ @Composable fun CpnLyric(modifier: Modifier) { MusicPlayController.curSongBean?.let { songBean -> val viewModel = viewModel { CpnLyricViewModel() } ViewStateComponent(modifier = modifier, key = "CpnLyric-${songBean.id}", loadDataBlock = { viewModel.getLyric(songBean.id) }, customLoadingComponent = { ViewStateTip("加载歌词中...") }, customEmptyComponent = { ViewStateTip("暂无歌词") }, customFailComponent = { _, loadDataBlock -> ViewStateTip("加载歌词出错, 点击重试", loadDataBlock) }, customErrorComponent = { _, loadDataBlock -> ViewStateTip("加载歌词出错, 点击重试", loadDataBlock) } ) { LyricList(it) } } } @Composable fun LyricList(data: LyricResult) { var cpnLyricHeight by remember { mutableStateOf(0) } val lazyListState = rememberLazyListState() val density = LocalDensity.current val viewModel = viewModel { CpnLyricViewModel() } LaunchedEffect(viewModel.curLyricIndex) { if (viewModel.curLyricIndex >= 0) { lazyListState.animateScrollToItem(viewModel.curLyricIndex) } } LazyColumn( modifier = Modifier .padding(top = 20.dp, bottom = 100.dp) .fillMaxSize() .onGloballyPositioned { cpnLyricHeight = it.size.height } .drawWithContent { val paint = Paint().asFrameworkPaint() drawIntoCanvas { val layerId: Int = it.nativeCanvas.saveLayer( 0f, 0f, size.width, size.height, paint ) drawContent() drawRect( brush = Brush.verticalGradient( Pair(0f, Color.Transparent), Pair(0.15f, Color.White), Pair(0.85f, Color.White), Pair(1f, Color.Transparent) ), blendMode = BlendMode.DstIn ) it.nativeCanvas.restoreToCount(layerId) } }, state = lazyListState, contentPadding = PaddingValues(vertical = (cpnLyricHeight * 0.4).convertDp(density)) ) { itemsIndexed(viewModel.lyricModelList) { index, item -> LyricItem(index, item, viewModel) } item { LyricConstructorInfo(data.lyricUser, data.transUser) } } } @Composable private fun ViewStateTip(tip: String, loadDataBlock: (() -> Unit)? = null) { Box( modifier = Modifier .fillMaxSize() .onClick { loadDataBlock?.invoke() }, contentAlignment = Alignment.Center ) { Text(text = tip, color = AppColorsProvider.current.secondText, fontSize = 16.sp) } } @Composable private fun LyricConstructorInfo(transUser: LyricContributorBean?, lyricUser: LyricContributorBean?) { Column( modifier = Modifier .fillMaxWidth() .padding(8.dp), verticalArrangement = Arrangement.Center, ) { lyricUser?.let { Text( text = "歌词贡献者:${it.nickname}", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = AppColorsProvider.current.secondText, ) } transUser?.let { Text( text = "翻译贡献者:${it.nickname}", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = AppColorsProvider.current.secondText, modifier = Modifier.padding(top = 8.dp) ) } } } @Composable private fun LyricItem(index: Int, lyricModel: LyricModel, viewModel: CpnLyricViewModel) { Column( modifier = Modifier .fillMaxWidth() .padding(8.dp), ) { lyricModel.lyric?.let { Text( text = it, fontSize = 14.sp, color = if (viewModel.curLyricIndex == index) AppColorsProvider.current.primary else AppColorsProvider.current.secondText, modifier = Modifier.fillMaxWidth(), ) } lyricModel.tLyric?.let { Text( text = it, fontSize = 12.sp, color = if (viewModel.curLyricIndex == index) AppColorsProvider.current.primary else AppColorsProvider.current.secondText, modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), ) } } } class CpnLyricViewModel : BaseViewModel(), IPlayerListener { var curLyricIndex by mutableStateOf(-1) val lyricModelList = mutableListOf() var curPlayPosition = 0 init { MusicPlayController.mediaPlayer.addListener(this) } override fun onCleared() { MusicPlayController.mediaPlayer.removeListener(this) super.onCleared() } fun getLyric(id: Long) = launchFlow(handleSuccessBlock = { lyricModelList.clear() lyricModelList.addAll(LyricUtil.parse(it)) curLyricIndex = lyricModelList.indexOfFirst { lyricModel -> curPlayPosition < lyricModel.time } - 1 }) { curPlayPosition = 0 NCRetrofitClient.getNCApi().getLyric(id) } override fun onStatusChanged(status: PlayerStatus) { } override fun onProgress(totalDuring: Int, currentPosition: Int, percentage: Float) { curPlayPosition = currentPosition curLyricIndex = lyricModelList.indexOfFirst { currentPosition < it.time } - 1 if (currentPosition > (lyricModelList.lastOrNull()?.time ?: 0)) { curLyricIndex = lyricModelList.size - 1 } } } data class LyricModel( val time: Long, val lyric: String? = null, var tLyric: String? = null ) ================================================ FILE: src/jvmMain/kotlin/ui/play/CpnMusicPlay.kt ================================================ package ui.play import androidx.compose.animation.core.* import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.MusicPlayController import moe.tlaster.precompose.ui.viewModel import moe.tlaster.precompose.viewmodel.ViewModel import ui.common.AsyncImage import ui.common.theme.AppColorsProvider const val DISK_ROTATE_ANIM_CYCLE = 10000 /** * 音乐播放组件 */ @Composable fun CpnMusicPlay(modifier: Modifier) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { Box(contentAlignment = Alignment.TopCenter) { Box(modifier = Modifier.padding(top = 80.dp)) { DiskRoundBackground() Disk() } DiskNeedle() } ActionIconRow() Spacer(modifier = Modifier.weight(1f)) CommentTitle() } } @Composable private fun DiskRoundBackground() { // 半透明圆形背景 Box( modifier = Modifier .width(328.dp) .height(328.dp) .clip(CircleShape) .background(Color(0x55EEEEEE)) ) } @Composable private fun BoxScope.Disk() { val viewModel: MusicPlayViewModel = viewModel { MusicPlayViewModel() } LaunchedEffect(MusicPlayController.curRealIndex) { viewModel.lastSheetDiskRotateAngleForSnap = 0f viewModel.sheetDiskRotate.snapTo(viewModel.lastSheetDiskRotateAngleForSnap) } LaunchedEffect(MusicPlayController.isPlaying()) { if (MusicPlayController.isPlaying()) { viewModel.sheetDiskRotate.animateTo( targetValue = 360f + viewModel.lastSheetDiskRotateAngleForSnap, animationSpec = infiniteRepeatable( animation = tween(durationMillis = DISK_ROTATE_ANIM_CYCLE, easing = LinearEasing), repeatMode = RepeatMode.Restart ) ) { viewModel.lastSheetDiskRotateAngleForSnap = viewModel.sheetDiskRotate.value } } else { viewModel.sheetDiskRotate.snapTo(viewModel.lastSheetDiskRotateAngleForSnap) viewModel.sheetDiskRotate.stop() } } Image( painter = painterResource("image/ic_disk_around.webp"), modifier = Modifier .width(320.dp) .height(320.dp) .align(Alignment.Center), contentDescription = "" ) MusicPlayController.curSongBean?.let { AsyncImage( modifier = Modifier .width(220.dp) .height(220.dp) .clip(CircleShape) .align(Alignment.Center) .border( width = 2.dp, color = Color.Black, shape = CircleShape ) .graphicsLayer { rotationZ = viewModel.sheetDiskRotate.value }, url = it.al.picUrl ) } } @Composable private fun DiskNeedle() { val needleRotateAnim by animateFloatAsState( targetValue = if (!MusicPlayController.isPlaying()) -25f else 0f, animationSpec = tween(durationMillis = 200, easing = LinearEasing) ) Image( painter = painterResource("image/ic_play_neddle.webp"), modifier = Modifier .padding(start = 72.dp) .width(114.dp) .height(174.dp) .graphicsLayer( rotationZ = needleRotateAnim, transformOrigin = TransformOrigin(0.164f, 0.109f) ), contentDescription = "" ) } @Composable private fun ActionIconRow() { Row(modifier = Modifier.padding(top = 20.dp)) { ActionIcon("image/ic_like.webp") ActionIcon("image/ic_collect.webp") ActionIcon("image/ic_download.webp") ActionIcon("image/ic_share.webp") } } @Composable private fun ActionIcon(icon: String) { Box( modifier = Modifier.padding(horizontal = 20.dp).background(AppColorsProvider.current.background, CircleShape) .size(44.dp), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(icon), contentDescription = null, modifier = Modifier.size(20.dp), tint = AppColorsProvider.current.firstIcon ) } } @Composable private fun CommentTitle() { Text( text = "听友评论", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = AppColorsProvider.current.firstText, modifier = Modifier.padding(start = 50.dp, bottom = 10.dp).fillMaxWidth() ) } class MusicPlayViewModel : ViewModel() { // disk旋转动画 val sheetDiskRotate by mutableStateOf(Animatable(0f)) // 上一次disk旋转角度 var lastSheetDiskRotateAngleForSnap = 0f // 是否抬起磁针 var sheetNeedleUp by mutableStateOf(true) } ================================================ FILE: src/jvmMain/kotlin/ui/play/CpnSongInfo.kt ================================================ package ui.play import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.MusicPlayController import ui.common.theme.AppColorsProvider @Composable fun ColumnScope.CpnSongInfo() { MusicPlayController.curSongBean?.let { songBean -> Text(songBean.name, color = AppColorsProvider.current.firstText, fontWeight = FontWeight.Bold, fontSize = 20.sp, modifier = Modifier.padding(top = 30.dp)) Row(modifier = Modifier.padding(top = 16.dp)) { InfoItem("专辑:", songBean.al.name) InfoItem("歌手:", songBean.ar.getOrNull(0)?.name ?: "未知歌手") } } } @Composable fun RowScope.InfoItem(key: String, value: String) { Row(modifier = Modifier.weight(1f)) { Text(key, color = AppColorsProvider.current.secondText, fontSize = 12.sp) Text(value, color = Color(0xFF5C8DD6), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } } ================================================ FILE: src/jvmMain/kotlin/ui/playlist/PlayListDetailPage.kt ================================================ package ui.playlist import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import model.SimplePlayListItem import moe.tlaster.precompose.ui.viewModel import ui.common.AsyncImage import ui.common.CommonTabLayout import ui.common.CommonTabLayoutStyle import ui.common.ExpandableText import ui.common.theme.AppColorsProvider import ui.main.cpn.CommonTitleBar import ui.playlist.cpn.* import util.StringUtil import util.TimeUtil import base.BaseViewModel /** * 歌单详情页面 */ @Composable fun PlayListDetailPage(simplePlayListItem: SimplePlayListItem) { val lazyListState = rememberLazyListState() var showStickyHeader by remember { mutableStateOf(false) } val detailPageViewModel = viewModel { PlayListDetailPageViewModel() } val trackListViewModel = viewModel { TrackListViewModel() } val commentViewModel = viewModel { CommentListViewModel() } val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } } LaunchedEffect(Unit) { snapshotFlow { firstVisibleItemIndex } .collect { firstVisibleItemIndex -> showStickyHeader = firstVisibleItemIndex > 0 && detailPageViewModel.selectedTabIndex.value == 0 } } // 首次获取数据 LaunchedEffect(detailPageViewModel.selectedTabIndex.value) { when (detailPageViewModel.selectedTabIndex.value) { 0 -> { // 歌曲列表 trackListViewModel.fetchData(simplePlayListItem.id, true) } 1 -> { // 评论 commentViewModel.fetchDataPaging("playlist", simplePlayListItem.id, 1, true) } } } val trackListViewState = trackListViewModel.flow?.collectAsState() val commentViewState = commentViewModel.flow?.collectAsState() Column { CommonTitleBar("歌单详情", showBackButton = true) Box { LazyColumn(state = lazyListState) { item { HeadInfo(simplePlayListItem) TabBar() } when (detailPageViewModel.selectedTabIndex.value) { 0 -> { // 歌曲列表 CpnTrackList(trackListViewState?.value) { trackListViewModel.fetchData(simplePlayListItem.id) } } 1 -> { // 评论 CpnCommentList(commentViewState?.value, commentViewModel) { curPage -> commentViewModel.fetchDataPaging("playlist", simplePlayListItem.id, curPage) } } 2 -> { // 收藏者 CpnPlayListSubscribers(trackListViewModel.playlistDetailResult?.playlist?.subscribers) } } } if (showStickyHeader) { StickyHeader(simplePlayListItem) } } } } @Composable private fun HeadInfo(simplePlayListItem: SimplePlayListItem) { val viewModel = viewModel { TrackListViewModel() } val playlistDetailResult = viewModel.playlistDetailResult Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { AsyncImage( modifier = Modifier.padding(end = 20.dp).size(216.dp).clip(RoundedCornerShape(6.dp)), simplePlayListItem.picUrl ) Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier.padding(end = 6.dp) .border(BorderStroke(1.dp, color = AppColorsProvider.current.primary), RoundedCornerShape(2.dp)) ) { Text( "歌单", color = AppColorsProvider.current.primary, modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp), fontSize = 14.sp ) } Text( simplePlayListItem.name, color = AppColorsProvider.current.firstText, modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), fontSize = 20.sp, fontWeight = FontWeight.Bold ) } Row( modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically ) { playlistDetailResult?.playlist?.creator?.let { creator -> AsyncImage(modifier = Modifier.clip(RoundedCornerShape(50)).size(20.dp), creator.avatarUrl, "image/ic_default_avator.webp", "image/ic_default_avator.webp") Text( creator.nickname, color = AppColorsProvider.current.firstText, modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), fontSize = 12.sp ) } Text( "${TimeUtil.parse(simplePlayListItem.trackNumberUpdateTime)}创建", color = AppColorsProvider.current.secondText, modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), fontSize = 12.sp ) } Row( modifier = Modifier.padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { HeadInfoActionButton( "播放全部", "image/ic_action_play.webp", AppColorsProvider.current.primary, Color.White, Color.White ) HeadInfoActionButton( "收藏(${StringUtil.friendlyNumber(playlistDetailResult?.playlist?.subscribedCount ?: simplePlayListItem.subscribedCount)})", "image/ic_collect.webp" ) HeadInfoActionButton( "分享(${StringUtil.friendlyNumber(playlistDetailResult?.playlist?.shareCount ?: simplePlayListItem.shareCount)})", "image/ic_share.webp" ) HeadInfoActionButton("下载", "image/ic_download.webp") } val tabs = remember(playlistDetailResult) { val sb = StringBuilder() sb.append("标签:") playlistDetailResult?.playlist?.tags?.forEachIndexed { index, tag -> sb.append(tag) if (index < playlistDetailResult.playlist.tags.size - 1) { sb.append("/") } } sb.toString() } Text( tabs, color = AppColorsProvider.current.firstIcon, modifier = Modifier.padding(vertical = 4.dp).padding(end = 20.dp), fontSize = 12.sp ) Row( modifier = Modifier.padding(top = 2.dp), verticalAlignment = Alignment.CenterVertically ) { Text( "歌曲数:${StringUtil.friendlyNumber(simplePlayListItem.trackCount)}", color = AppColorsProvider.current.firstIcon, modifier = Modifier.padding(end = 20.dp), fontSize = 12.sp ) Text( "播放数:${StringUtil.friendlyNumber(simplePlayListItem.playCount)}", color = AppColorsProvider.current.firstIcon, fontSize = 12.sp ) } ExpandableText( text = "简介:${playlistDetailResult?.playlist?.description ?: simplePlayListItem.copywriter ?: "暂无"}", color = AppColorsProvider.current.firstIcon, modifier = Modifier.padding(top = 6.dp).padding(end = 20.dp), fontSize = 12.sp ) } } } @Composable private fun HeadInfoActionButton( title: String, icon: String, bgColor: Color = Color.Transparent, textColor: Color = AppColorsProvider.current.firstText, iconTint: Color = AppColorsProvider.current.firstIcon, ) { Row( modifier = Modifier.padding(end = 8.dp) .clip(RoundedCornerShape(50)) .background(bgColor) .border(BorderStroke(1.dp, color = AppColorsProvider.current.divider), RoundedCornerShape(50)) .padding(horizontal = 14.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Icon( painter = painterResource(icon), modifier = Modifier.padding(end = 8.dp).size(20.dp).clip(RoundedCornerShape(6.dp)), contentDescription = "", tint = iconTint ) Text( title, color = textColor, fontSize = 14.sp ) } } @Composable private fun StickyHeader(simplePlayListItem: SimplePlayListItem) { Column( modifier = Modifier.fillMaxWidth().background(AppColorsProvider.current.pure) .padding(start = 30.dp, top = 10.dp, end = 30.dp) ) { Text( simplePlayListItem.name, color = AppColorsProvider.current.firstText, fontSize = 20.sp, fontWeight = FontWeight.Bold ) Row(modifier = Modifier.padding(vertical = 12.dp)) { Icon( painter = painterResource("image/ic_action_play.webp"), modifier = Modifier.padding(end = 30.dp).size(24.dp), contentDescription = "", tint = AppColorsProvider.current.primary ) Icon( painter = painterResource("image/ic_collect.webp"), modifier = Modifier.padding(end = 30.dp).size(22.dp), contentDescription = "", tint = AppColorsProvider.current.firstIcon ) Icon( painter = painterResource("image/ic_share.webp"), modifier = Modifier.padding(end = 30.dp).size(22.dp), contentDescription = "", tint = AppColorsProvider.current.firstIcon ) Icon( painter = painterResource("image/ic_download.webp"), modifier = Modifier.padding(end = 30.dp).size(22.dp), contentDescription = "", tint = AppColorsProvider.current.firstIcon ) } Divider(modifier = Modifier.fillMaxWidth(), color = AppColorsProvider.current.divider) } } @Composable private fun TabBar() { val datailViewModel = viewModel { PlayListDetailPageViewModel() } val trackListViewModel = viewModel { TrackListViewModel() } val tabs = remember(trackListViewModel.playlistDetailResult) { listOf( "歌曲列表", "评论(${StringUtil.friendlyNumber(trackListViewModel.playlistDetailResult?.playlist?.commentCount)})", "收藏者" ) } CommonTabLayout( selectedIndex = datailViewModel.selectedTabIndex.value, tabTexts = tabs, backgroundColor = Color.Transparent, style = CommonTabLayoutStyle(modifier = Modifier.height(40.dp), showIndicator = true) ) { datailViewModel.selectedTabIndex.value = it } Divider( modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(), color = AppColorsProvider.current.divider, thickness = 1.dp ) } class PlayListDetailPageViewModel : BaseViewModel() { val selectedTabIndex = mutableStateOf(0) } ================================================ FILE: src/jvmMain/kotlin/ui/playlist/cpn/CpnPlayListCommentList.kt ================================================ package ui.playlist.cpn import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import model.CommentBean import model.CommentResult import http.NCRetrofitClient import ui.common.AsyncImage import ui.common.PaingFooterNumBar import ui.common.handleListContent import ui.common.theme.AppColorsProvider import util.TimeUtil import base.BaseViewModel import base.ViewState import base.ViewStateMutableStateFlow /** * 评论组件 */ fun LazyListScope.CpnCommentList( viewState: ViewState?, viewModel: CommentListViewModel, reloadCallback: (curPage: Int) -> Unit ) { handleListContent(viewState, reloadDataBlock = { reloadCallback.invoke(viewModel.cutPage) }) { data -> items(data.comments.size) { CommentItem(data.comments[it]) } // 底部分页组件 if (data.total > CommentListViewModel.pageSize) { item { PaingFooterNumBar(data.total, CommentListViewModel.pageSize, viewModel.cutPage) { reloadCallback.invoke(it) } } } } } @Composable private fun CommentItem(commentBean: CommentBean) { Row(modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp)) { AsyncImage( modifier = Modifier.padding(end = 10.dp).size(48.dp).clip(RoundedCornerShape(50)), url = commentBean.user.avatarUrl, "image/ic_default_avator.webp" ) Column { Text( text = buildAnnotatedString { withStyle(style = SpanStyle(color = Color(0xFF5C8DD6), fontSize = 14.sp)) { append(commentBean.user.nickname) } withStyle(style = SpanStyle(color = AppColorsProvider.current.firstText, fontSize = 14.sp)) { append(" : ${commentBean.content}") } }, ) Row(verticalAlignment = Alignment.CenterVertically) { Text( text = TimeUtil.parse(commentBean.time), color = AppColorsProvider.current.thirdText, fontSize = 12.sp, modifier = Modifier.padding(vertical = 8.dp).weight(1f) ) Icon( painter = painterResource("image/ic_thumbs_up.webp"), modifier = Modifier.size(14.dp), contentDescription = "", tint = AppColorsProvider.current.secondText ) Divider( modifier = Modifier.padding(horizontal = 14.dp).width(0.5.dp), color = AppColorsProvider.current.divider, thickness = 10.dp ) Icon( painter = painterResource("image/ic_share.webp"), modifier = Modifier.size(14.dp), contentDescription = "", tint = AppColorsProvider.current.secondIcon ) Divider( modifier = Modifier.padding(horizontal = 14.dp).width(0.5.dp), color = AppColorsProvider.current.divider, thickness = 10.dp ) Icon( painter = painterResource("image/ic_comment.webp"), modifier = Modifier.size(14.dp), contentDescription = "", tint = AppColorsProvider.current.secondIcon ) } Divider(modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), color = AppColorsProvider.current.divider, thickness = 0.5.dp) } } } class CommentListViewModel : BaseViewModel() { companion object { const val pageSize = 20 } var cutPage by mutableStateOf(1) var flow by mutableStateOf?>(null) fun fetchDataPaging(commentType: String, id: Long, curPage: Int, firstLoad: Boolean = false) { if (!firstLoad || flow == null) { cutPage = curPage val offset = (curPage - 1) * pageSize flow = launchFlow { println("获取${commentType}评论. curPage=${curPage}") NCRetrofitClient.getNCApi().getCommentList(commentType, id, pageSize, offset) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/playlist/cpn/CpnPlayListSubscribers.kt ================================================ package ui.playlist.cpn import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import model.Subscribers import ui.common.AsyncImage import ui.common.ListToGridItems import ui.common.NoSuccessComponent import ui.common.theme.AppColorsProvider import util.StringUtil /** * 订阅者组件 */ fun LazyListScope.CpnPlayListSubscribers(subscribes: List?) { //subscribes: List if (subscribes.isNullOrEmpty()) { item { NoSuccessComponent() } } else { ListToGridItems(subscribes, 2) { _, item -> SubscribersItem(item) } } } @Composable private fun SubscribersItem(item: Subscribers) { Row( modifier = Modifier.padding(start = 20.dp).fillMaxWidth().height(120.dp), verticalAlignment = Alignment.CenterVertically ) { AsyncImage( modifier = Modifier.padding(end = 10.dp).size(90.dp).clip(RoundedCornerShape(50)), item.avatarUrl, "image/ic_default_avator.webp", "image/ic_default_avator.webp" ) Column { Text( item.nickname, color = AppColorsProvider.current.firstText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (!StringUtil.isEmpty(item.description)) { Text( item.description!!, color = AppColorsProvider.current.firstText, fontSize = 12.sp, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 10.dp, end = 10.dp) ) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/playlist/cpn/CpnTrackList.kt ================================================ package ui.playlist.cpn import androidx.compose.foundation.background import ui.common.onClick import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import base.MusicPlayController import http.NCRetrofitClient import model.PlaylistDetailResult import model.SongBean import model.SongDetailResult import ui.common.handleListContent import ui.common.theme.AppColorsProvider import base.BaseViewModel import base.ViewState import base.ViewStateMutableStateFlow /** * 歌曲列表组件 */ fun LazyListScope.CpnTrackList( viewState: ViewState?, reloadCallback: () -> Unit ) { handleListContent(viewState, reloadDataBlock = { reloadCallback.invoke() }) { data -> item { TrackHeaderBar() } items(data.songs.size) { TrackItem(data.songs, it) } } } @Composable private fun TrackHeaderBar() { Row( modifier = Modifier.height(36.dp).fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically ) { Text( modifier = Modifier.weight(4f), text = "音乐标题", color = AppColorsProvider.current.thirdText, fontSize = 12.sp, textAlign = TextAlign.Center ) Text( modifier = Modifier.weight(2f), text = "歌手", color = AppColorsProvider.current.thirdText, fontSize = 12.sp, textAlign = TextAlign.Center ) Text( modifier = Modifier.weight(2f), text = "专辑", color = AppColorsProvider.current.thirdText, fontSize = 12.sp, textAlign = TextAlign.Center ) Text( modifier = Modifier.weight(1f), text = "时长", color = AppColorsProvider.current.thirdText, fontSize = 12.sp, textAlign = TextAlign.Center ) } } @Composable private fun TrackItem(songList: List, index: Int) { val curSongBean = songList[index] Row( modifier = Modifier .onClick { MusicPlayController.playMusicList(songList, index) } .background( if ((index + 1) % 2 == 0) Color.Transparent else AppColorsProvider.current.divider.copy( 0.25f ) ).height(36.dp).fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically ) { Row(modifier = Modifier.weight(4f).fillMaxHeight(), verticalAlignment = Alignment.CenterVertically) { val num = if (index + 1 < 10) "0${index + 1}" else (index + 1).toString() if (MusicPlayController.curSongBean?.id != curSongBean.id) { Text( text = num, color = AppColorsProvider.current.thirdText, fontSize = 12.sp, modifier = Modifier.width(40.dp), textAlign = TextAlign.Center ) } else { Icon( painterResource("image/ic_playing.webp"), contentDescription = null, modifier = Modifier.width(40.dp).height(36.dp).padding(horizontal = 14.dp, vertical = 12.dp), tint = AppColorsProvider.current.primary ) } Icon( painter = painterResource("image/ic_like.webp"), modifier = Modifier.padding(end = 8.dp).size(14.dp), contentDescription = "", tint = AppColorsProvider.current.secondText ) Icon( painter = painterResource("image/ic_download.webp"), modifier = Modifier.padding(end = 10.dp).size(14.dp), contentDescription = "", tint = AppColorsProvider.current.secondIcon ) Text( text = curSongBean.name, color = AppColorsProvider.current.firstText, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, textAlign = TextAlign.Center ) } Text( modifier = Modifier.weight(2f), text = curSongBean.ar.getOrNull(0)?.name ?: "未知歌手", color = AppColorsProvider.current.secondText, fontSize = 12.sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.weight(2f), text = curSongBean.al.name, color = AppColorsProvider.current.secondText, fontSize = 12.sp, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( modifier = Modifier.weight(1f), text = curSongBean.getSongTimeLength(), color = AppColorsProvider.current.secondText, fontSize = 12.sp, textAlign = TextAlign.Center ) } } class TrackListViewModel : BaseViewModel() { var flow by mutableStateOf?>(null) var playlistDetailResult by mutableStateOf(null) fun fetchData(id: Long, firstLoad: Boolean = false) { if (!firstLoad || flow == null) { flow = launchFlow { val playlistDetailResult = NCRetrofitClient.getNCApi().getPlaylistDetail(id) this.playlistDetailResult = playlistDetailResult val trackIdBeans = playlistDetailResult.playlist.trackIds val ids = StringBuilder() if (trackIdBeans != null) { val size = trackIdBeans.size for (i in 0 until size) { //最后一个参数不加逗号 if (i == size - 1) { ids.append(trackIdBeans[i].id) } else { ids.append(trackIdBeans[i].id).append(",") } } } NCRetrofitClient.getNCApi().getSongDetail(ids.toString()) } } } } ================================================ FILE: src/jvmMain/kotlin/ui/setting/SettingPage.kt ================================================ package ui.setting import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import ui.common.theme.AppColorsProvider import ui.main.cpn.CommonTitleBar @Composable fun SettingPage() { Column(modifier = Modifier.fillMaxSize()) { CommonTitleBar("设置", true) Text( text = "声明", modifier = Modifier.padding(20.dp), fontSize = 18.sp, color = AppColorsProvider.current.firstText, fontWeight = FontWeight.Bold ) Text( text = "本应用非 网易云音乐 官方产品,内部所有资源来自互联网,仅作学习分享使用,他人如何使用此应用与本应用无关。", modifier = Modifier.padding(horizontal = 20.dp), fontSize = 14.sp, color = AppColorsProvider.current.secondText, ) } } ================================================ FILE: src/jvmMain/kotlin/ui/theme/Shape.kt ================================================ package ui.common.theme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes import androidx.compose.ui.unit.dp val Shapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(8.dp), large = RoundedCornerShape(12.dp) ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/Theme.kt ================================================ package ui.common.theme import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.TweenSpec import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme.shapes import androidx.compose.runtime.* import ui.common.theme.color.AppColors import ui.common.theme.color.light.* // 夜间模式 const val THEME_NIGHT = -1 // 默认主题 const val THEME_DEFAULT = 0 // 蓝色主题 const val THEME_BLUE = 1 // 绿色主题 const val THEME_GREEN = 2 // 橙色主题 const val THEME_ORIGIN = 3 // 紫色主题 const val THEME_PURPLE = 4 // 黄色主题 const val THEME_YELLOW = 5 /** * 主题状态 */ val themeTypeState: MutableState by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { mutableStateOf(getDefaultThemeId()) } var AppColorsProvider = compositionLocalOf { DefaultColorPalette } /** * 获取当前默认主题 */ fun getDefaultThemeId(): Int = THEME_DEFAULT const val TWEEN_DURATION = 200 @Composable @ReadOnlyComposable fun isInDarkTheme(): Boolean { return isSystemInDarkTheme() || themeTypeState.value == THEME_NIGHT } @Composable fun AppTheme( themeType: Int, isDark: Boolean = isInDarkTheme(), content: @Composable () -> Unit ) { val targetColors = if (isDark) DarkColorPalette else { when (themeType) { THEME_BLUE -> BlueColorPalette THEME_GREEN -> GreenColorPalette THEME_ORIGIN -> OriginColorPalette THEME_PURPLE -> PurpleColorPalette THEME_YELLOW -> YellowColorPalette else -> DefaultColorPalette } } val topBarColor = animateColorAsState(targetColors.topBarColor, TweenSpec(TWEEN_DURATION)) val pure = animateColorAsState(targetColors.pure, TweenSpec(TWEEN_DURATION)) val primary = animateColorAsState(targetColors.primary, TweenSpec(TWEEN_DURATION)) val primaryVariant = animateColorAsState(targetColors.primaryVariant, TweenSpec(TWEEN_DURATION)) val secondary = animateColorAsState(targetColors.secondary, TweenSpec(TWEEN_DURATION)) val background = animateColorAsState(targetColors.background, TweenSpec(TWEEN_DURATION)) val firstText = animateColorAsState(targetColors.firstText, TweenSpec(TWEEN_DURATION)) val secondText = animateColorAsState(targetColors.secondText, TweenSpec(TWEEN_DURATION)) val thirdText = animateColorAsState(targetColors.thirdText, TweenSpec(TWEEN_DURATION)) val firstIcon = animateColorAsState(targetColors.firstIcon, TweenSpec(TWEEN_DURATION)) val secondIcon = animateColorAsState(targetColors.secondIcon, TweenSpec(TWEEN_DURATION)) val thirdIcon = animateColorAsState(targetColors.thirdIcon, TweenSpec(TWEEN_DURATION)) val card = animateColorAsState(targetColors.card, TweenSpec(TWEEN_DURATION)) val divider = animateColorAsState(targetColors.divider, TweenSpec(TWEEN_DURATION)) val appColors = AppColors( topBar = topBarColor.value, pure = pure.value, primary = primary.value, primaryVariant = primaryVariant.value, secondary = secondary.value, background = background.value, firstText = firstText.value, secondText = secondText.value, thirdText = thirdText.value, firstIcon = firstIcon.value, secondIcon = secondIcon.value, thirdIcon = thirdIcon.value, card = card.value, divider = divider.value ) CompositionLocalProvider(AppColorsProvider provides appColors) { MaterialTheme( shapes = shapes ) { content() } } } ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/AppColors.kt ================================================ package ui.common.theme.color import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color /** * Created by ssk on 2022/4/17. */ @Stable class AppColors( topBar: Color, pure: Color, primary: Color, primaryVariant: Color, secondary: Color, background: Color, firstText: Color, secondText: Color, thirdText: Color, firstIcon: Color, secondIcon: Color, thirdIcon: Color, card: Color, divider: Color ) { var topBarColor: Color by mutableStateOf(topBar) internal set var pure : Color by mutableStateOf(pure) internal set var primary: Color by mutableStateOf(primary) internal set var primaryVariant: Color by mutableStateOf(primaryVariant) internal set var secondary: Color by mutableStateOf(secondary) internal set var background: Color by mutableStateOf(background) private set var firstText: Color by mutableStateOf(firstText) private set var secondText: Color by mutableStateOf(secondText) private set var thirdText: Color by mutableStateOf(thirdText) private set var firstIcon: Color by mutableStateOf(firstIcon) private set var secondIcon: Color by mutableStateOf(secondIcon) private set var thirdIcon: Color by mutableStateOf(thirdIcon) private set var card: Color by mutableStateOf(card) private set var divider: Color by mutableStateOf(divider) private set } ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/dark/DartColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/4/17. */ val DarkColorPalette = AppColors( topBar = Color(0xFF222222), pure = Color(0xFF000000), primary = Color(0xFFF0484E), primaryVariant = Color(0xFFEC3037), secondary = Color(0xFFF0888C), background = Color(0xFF222222), firstText = Color(0xFFFFFFFF), secondText = Color(0xFFBBBBBB), thirdText = Color(0xFF999999), firstIcon = Color(0xFFFFFFFF), secondIcon = Color(0xFFBBBBBB), thirdIcon = Color(0xFF999999), card = Color(0xFF333333), divider = Color(0xFF555555), ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/light/BlueColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/4/17. */ val BlueColorPalette = AppColors( topBar = Color(0xFFF3F3F3), pure = Color(0xFFFFFFFF), primary = Color(0xFF3050EE), primaryVariant = Color(0xFF102DB9), secondary = Color(0xFF789BF1), background = Color(0xFFEEEEEE), firstText = Color(0xFF333333), secondText = Color(0xFF666666), thirdText = Color(0xFF999999), firstIcon = Color(0xFF333333), secondIcon = Color(0xFF666666), thirdIcon = Color(0xFF999999), card = Color(0xFFFFFFFF), divider = Color(0xFFDDDDDD), ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/light/DefaultColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/4/17. */ val DefaultColorPalette = AppColors( topBar = Color(0xFFF3F3F3), pure = Color(0xFFFFFFFF), primary = Color(0xFFF0484E), primaryVariant = Color(0xFFEC3037), secondary = Color(0xFFF0888C), background = Color(0xFFEEEEEE), firstText = Color(0xFF333333), secondText = Color(0xFF666666), thirdText = Color(0xFF999999), firstIcon = Color(0xFF333333), secondIcon = Color(0xFF666666), thirdIcon = Color(0xFF999999), card = Color(0xFFFFFFFF), divider = Color(0xFFDDDDDD), ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/light/GreenColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/4/17. */ val GreenColorPalette = AppColors( topBar = Color(0xFFF3F3F3), pure = Color(0xFFFFFFFF), primary = Color(0xFF3EC73E), primaryVariant = Color(0xFF129912), secondary = Color(0xFF9FE69F), background = Color(0xFFEEEEEE), firstText = Color(0xFF333333), secondText = Color(0xFF666666), thirdText = Color(0xFF999999), firstIcon = Color(0xFF333333), secondIcon = Color(0xFF666666), thirdIcon = Color(0xFF999999), card = Color(0xFFFFFFFF), divider = Color(0xFFDDDDDD), ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/light/OriginColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/4/17. */ val OriginColorPalette = AppColors( topBar = Color(0xFFF3F3F3), pure = Color(0xFFFFFFFF), primary = Color(0xFFFF6633), primaryVariant = Color(0xFFD6410F), secondary = Color(0xFFF3906F), background = Color(0xFFEEEEEE), firstText = Color(0xFF333333), secondText = Color(0xFF666666), thirdText = Color(0xFF999999), firstIcon = Color(0xFF333333), secondIcon = Color(0xFF666666), thirdIcon = Color(0xFF999999), card = Color(0xFFFFFFFF), divider = Color(0xFFDDDDDD), ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/light/PurpleColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/5/13. */ val PurpleColorPalette = AppColors( topBar = Color(0xFFF3F3F3), pure = Color(0xFFFFFFFF), primary = Color(0xFFEE3DEE), primaryVariant = Color(0xFFB917B9), secondary = Color(0xFFEC8FEC), background = Color(0xFFEEEEEE), firstText = Color(0xFF333333), secondText = Color(0xFF666666), thirdText = Color(0xFF999999), firstIcon = Color(0xFF333333), secondIcon = Color(0xFF666666), thirdIcon = Color(0xFF999999), card = Color(0xFFFFFFFF), divider = Color(0xFFDDDDDD), ) ================================================ FILE: src/jvmMain/kotlin/ui/theme/color/palette/light/YellowColorPalette.kt ================================================ package ui.common.theme.color.light import androidx.compose.ui.graphics.Color import ui.common.theme.color.AppColors /** * Created by ssk on 2022/4/17. */ val YellowColorPalette = AppColors( topBar = Color(0xFFF3F3F3), pure = Color(0xFFFFFFFF), primary = Color(0xFFFFF143), primaryVariant = Color(0xFFC7B917), secondary = Color(0xFFF8F8AF), background = Color(0xFFEEEEEE), firstText = Color(0xFF333333), secondText = Color(0xFF666666), thirdText = Color(0xFF999999), firstIcon = Color(0xFF333333), secondIcon = Color(0xFF666666), thirdIcon = Color(0xFF999999), card = Color(0xFFFFFFFF), divider = Color(0xFFDDDDDD), ) ================================================ FILE: src/jvmMain/kotlin/ui/todo/TestPage.kt ================================================ package ui.todo import androidx.compose.foundation.layout.* import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.layout.* @Composable fun TestPage() { } ================================================ FILE: src/jvmMain/kotlin/ui/todo/TodoPage.kt ================================================ package ui.todo import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import ui.common.theme.AppColorsProvider import ui.main.cpn.CommonTitleBar @Composable fun TodoPage(tag: String, showTitle: Boolean = true) { Column { if (showTitle) { CommonTitleBar(tag, true) } Box(Modifier.fillMaxSize().background(AppColorsProvider.current.pure), contentAlignment = Alignment.Center ) { Text("todo-${tag}", color = AppColorsProvider.current.firstText) } } } ================================================ FILE: src/jvmMain/kotlin/util/DataStoreUtils.kt ================================================ package util import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import java.io.IOException object DataStoreUtils { /** * 此文件路径可进行修改,但后缀名不可进行修改 * System.getProperty("user.dir") 可以获取到当前路径 */ private val dataStore: DataStore = getDataStore() @Suppress("UNCHECKED_CAST") fun getSyncData(key: String, default: U): U { val res = when (default) { is Long -> readLongData(key, default) is String -> readStringData(key, default) is Int -> readIntData(key, default) is Boolean -> readBooleanData(key, default) is Float -> readFloatData(key, default) else -> throw IllegalArgumentException("This type can be saved into DataStore") } return res as U } @Suppress("UNCHECKED_CAST") fun getData(key: String, default: U): Flow { val data = when (default) { is Long -> readLongFlow(key, default) is String -> readStringFlow(key, default) is Int -> readIntFlow(key, default) is Boolean -> readBooleanFlow(key, default) is Float -> readFloatFlow(key, default) else -> throw IllegalArgumentException("This type can be saved into DataStore") } return data as Flow } suspend fun putData(key: String, value: U) { when (value) { is Long -> saveLongData(key, value) is String -> saveStringData(key, value) is Int -> saveIntData(key, value) is Boolean -> saveBooleanData(key, value) is Float -> saveFloatData(key, value) else -> throw IllegalArgumentException("This type can be saved into DataStore") } } fun putSyncData(key: String, value: U) { when (value) { is Long -> saveSyncLongData(key, value) is String -> saveSyncStringData(key, value) is Int -> saveSyncIntData(key, value) is Boolean -> saveSyncBooleanData(key, value) is Float -> saveSyncFloatData(key, value) else -> throw IllegalArgumentException("This type can be saved into DataStore") } } fun readBooleanFlow(key: String, default: Boolean = false): Flow = dataStore.data .catch { //当读取数据遇到错误时,如果是 `IOException` 异常,发送一个 emptyPreferences 来重新使用 //但是如果是其他的异常,最好将它抛出去,不要隐藏问题 if (it is IOException) { it.printStackTrace() emit(emptyPreferences()) } else { throw it } }.map { it[booleanPreferencesKey(key)] ?: default } fun readBooleanData(key: String, default: Boolean = false): Boolean { var value = false runBlocking { dataStore.data.first { value = it[booleanPreferencesKey(key)] ?: default true } } return value } fun readIntFlow(key: String, default: Int = 0): Flow = dataStore.data .catch { if (it is IOException) { it.printStackTrace() emit(emptyPreferences()) } else { throw it } }.map { it[intPreferencesKey(key)] ?: default } fun readIntData(key: String, default: Int = 0): Int { var value = 0 runBlocking { dataStore.data.first { value = it[intPreferencesKey(key)] ?: default true } } return value } fun readStringFlow(key: String, default: String = ""): Flow = dataStore.data .catch { if (it is IOException) { it.printStackTrace() emit(emptyPreferences()) } else { throw it } }.map { it[stringPreferencesKey(key)] ?: default } fun readStringData(key: String, default: String = ""): String { var value = "" runBlocking { dataStore.data.first { value = it[stringPreferencesKey(key)] ?: default true } } return value } fun readFloatFlow(key: String, default: Float = 0f): Flow = dataStore.data .catch { if (it is IOException) { it.printStackTrace() emit(emptyPreferences()) } else { throw it } }.map { it[floatPreferencesKey(key)] ?: default } fun readFloatData(key: String, default: Float = 0f): Float { var value = 0f runBlocking { dataStore.data.first { value = it[floatPreferencesKey(key)] ?: default true } } return value } fun readLongFlow(key: String, default: Long = 0L): Flow = dataStore.data .catch { if (it is IOException) { it.printStackTrace() emit(emptyPreferences()) } else { throw it } }.map { it[longPreferencesKey(key)] ?: default } fun readLongData(key: String, default: Long = 0L): Long { var value = 0L runBlocking { dataStore.data.first { value = it[longPreferencesKey(key)] ?: default true } } return value } suspend fun saveBooleanData(key: String, value: Boolean) { dataStore.edit { mutablePreferences -> mutablePreferences[booleanPreferencesKey(key)] = value } } fun saveSyncBooleanData(key: String, value: Boolean) = runBlocking { saveBooleanData(key, value) } suspend fun saveIntData(key: String, value: Int) { dataStore.edit { mutablePreferences -> mutablePreferences[intPreferencesKey(key)] = value } } fun saveSyncIntData(key: String, value: Int) = runBlocking { saveIntData(key, value) } suspend fun saveStringData(key: String, value: String) { dataStore.edit { mutablePreferences -> mutablePreferences[stringPreferencesKey(key)] = value } } fun saveSyncStringData(key: String, value: String) = runBlocking { saveStringData(key, value) } suspend fun saveFloatData(key: String, value: Float) { dataStore.edit { mutablePreferences -> mutablePreferences[floatPreferencesKey(key)] = value } } fun saveSyncFloatData(key: String, value: Float) = runBlocking { saveFloatData(key, value) } suspend fun saveLongData(key: String, value: Long) { dataStore.edit { mutablePreferences -> mutablePreferences[longPreferencesKey(key)] = value } } fun saveSyncLongData(key: String, value: Long) = runBlocking { saveLongData(key, value) } suspend fun clear() { dataStore.edit { it.clear() } } fun clearSync() { runBlocking { dataStore.edit { it.clear() } } } } ================================================ FILE: src/jvmMain/kotlin/util/DensityExt.kt ================================================ package util import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp fun Number.convertDp(density: Density): Dp { return (toFloat() / density.density).dp } ================================================ FILE: src/jvmMain/kotlin/util/EnvUtil.kt ================================================ package util import java.util.* object EnvUtil { val osName = System.getProperty("os.name", "generic") fun isMac() = osName.lowercase(Locale.getDefault()).contains("mac") fun isWindows() = osName.contains("indows") fun isLinux() = osName.contains("nix") || osName.contains("nux") || osName.contains("aix") } ================================================ FILE: src/jvmMain/kotlin/util/LyricUtil.kt ================================================ package util import model.LyricResult import ui.play.LyricModel import java.util.* import java.util.regex.Matcher import java.util.regex.Pattern /** * Created by ssk on 2022/5/11. */ object LyricUtil { private val MINUTE_IN_MILLIS = 60000L private val SECOND_IN_MILLIS = 1000L private val PATTERN_LINE = Pattern.compile("((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)") private val PATTERN_TIME = Pattern.compile("\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]") fun parse(lyricResult: LyricResult): List { val originLrcTexts = lyricResult.lrc?.lyric ?: "" val originTLyricTexts = lyricResult.tlyric?.lyric ?: "" val lyricModelList = parseLyrics(originLrcTexts) val tLyricModelList = parseTlyrics(originTLyricTexts) lyricModelList.forEach { lyricModel -> tLyricModelList.forEach inner@{ tLyricModel -> if (lyricModel.time == tLyricModel.time) { lyricModel.tLyric = tLyricModel.tLyric return@inner } } } return lyricModelList } /** * 从文本解析歌词 */ private fun parseLyrics(lyric: String): List { var lrcText = lyric val entryList = ArrayList() if (!StringUtil.isEmpty(lrcText)) { if (lrcText.startsWith("\uFEFF")) { lrcText = lrcText.replace("\uFEFF", "") } val array = lrcText.split("\\n".toRegex()).toTypedArray() for (line in array) { val list = parseLyricsLine(line) if (list != null && list.isNotEmpty()) { entryList.addAll(list) } } } return entryList } /** * 从文本解析歌词 */ private fun parseTlyrics(tLyric: String): List { var tlyric = tLyric val entryList = ArrayList() if (!StringUtil.isEmpty(tlyric)) { if (tlyric.startsWith("\uFEFF")) { tlyric = tlyric.replace("\uFEFF", "") } val array = tlyric.split("\\n".toRegex()).toTypedArray() for (line in array) { val list = parseTLyricsLine(line) if (list != null && list.isNotEmpty()) { entryList.addAll(list) } } } return entryList } /** * 解析一行歌词 */ private fun parseLyricsLine(line: String): List? { var line = line if (StringUtil.isEmpty(line)) { return null } line = line.trim { it <= ' ' } // [00:17.65]让我掉下眼泪的 val lineMatcher: Matcher = PATTERN_LINE.matcher(line) if (!lineMatcher.matches()) { return null } val times = lineMatcher.group(1) val text = lineMatcher.group(3) val entryList: MutableList = ArrayList() // [00:17.65] val timeMatcher: Matcher = PATTERN_TIME.matcher(times) while (timeMatcher.find()) { val min = timeMatcher.group(1)!!.toLong() val sec = timeMatcher.group(2)!!.toLong() val milString = timeMatcher.group(3) var mil = milString.toLong() // 如果毫秒是两位数,需要乘以10 if (milString.length == 2) { mil *= 10 } val time = min * MINUTE_IN_MILLIS + sec * SECOND_IN_MILLIS + mil entryList.add(LyricModel(time, text)) } return entryList } /** * 解析一行歌词 */ private fun parseTLyricsLine(line: String): List? { var line = line if (StringUtil.isEmpty(line)) { return null } line = line.trim { it <= ' ' } // [00:17.65]让我掉下眼泪的 val lineMatcher: Matcher = PATTERN_LINE.matcher(line) if (!lineMatcher.matches()) { return null } val times = lineMatcher.group(1) val text = lineMatcher.group(3) val entryList: MutableList = ArrayList() // [00:17.65] val timeMatcher: Matcher = PATTERN_TIME.matcher(times) while (timeMatcher.find()) { val min = timeMatcher.group(1)!!.toLong() val sec = timeMatcher.group(2)!!.toLong() val milString = timeMatcher.group(3) var mil = milString.toLong() // 如果毫秒是两位数,需要乘以10 if (milString.length == 2) { mil *= 10 } val time = min * MINUTE_IN_MILLIS + sec * SECOND_IN_MILLIS + mil entryList.add(LyricModel(time, tLyric = text)) } return entryList } /** * 转为[分:秒] */ fun formatTime(milli: Long): String? { val m = (milli / MINUTE_IN_MILLIS).toInt() val s = (milli / SECOND_IN_MILLIS % 60).toInt() val mm = String.format(Locale.getDefault(), "%02d", m) val ss = String.format(Locale.getDefault(), "%02d", s) return "$mm:$ss" } } ================================================ FILE: src/jvmMain/kotlin/util/QrcodeUtil.kt ================================================ package util import base.AppConfig import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.MultiFormatWriter import com.google.zxing.WriterException import com.google.zxing.client.j2se.MatrixToImageWriter import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import java.io.File import java.util.* /** * Created by ssk on 2023/2/8. */ object QrcodeUtil { /** * 创建二维码图片 */ fun createQrcodeFile( qrcodeStr: String, width: Int = 400, height: Int = 400, ): File? { // 用于设置QR二维码参数 val qrParam = Hashtable() // 设置QR二维码的纠错级别——这里选择最高H级别 qrParam[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.H // 设置编码方式 qrParam[EncodeHintType.CHARACTER_SET] = "UTF-8" try { val bitMatrix = MultiFormatWriter().encode( qrcodeStr, BarcodeFormat.QR_CODE, width, height, qrParam ) // val file = File(System.getProperty("user.dir") + File.separator + "cache" + File.separator + "qrcode.png") val file = File(AppConfig.cacheRootDir + File.separator + "cache" + File.separator + "qrcode.png") if (!file.parentFile.exists()) { file.parentFile.mkdirs() } val path = file.toPath() MatrixToImageWriter.writeToPath(bitMatrix, "png", path) return file } catch (e: WriterException) { e.printStackTrace() } return null } } ================================================ FILE: src/jvmMain/kotlin/util/StringUtil.kt ================================================ package util import java.util.* /** * Created by ssk on 2022/4/23. */ object StringUtil { fun friendlyNumber(num: Number?): String { if (num != null) { if(num.toLong() < 10000) { return num.toString() }else if(num.toLong() < 100000000) { val result = num.toLong() / 10000 return result.toString() + "万" }else if(num.toLong() >= 100000000) { val result = num.toLong() / 100000000 return result.toString() + "亿" } return num.toString() } else { return "0" } } fun formatMilliseconds(milliseconds: Int): String { val standardTime: String val seconds = milliseconds / 1000 if (seconds <= 0) { standardTime = "00:00" } else if (seconds < 60) { standardTime = String.format(Locale.getDefault(), "00:%02d", seconds % 60) } else if (seconds < 3600) { standardTime = java.lang.String.format( Locale.getDefault(), "%02d:%02d", seconds / 60, seconds % 60 ) } else { standardTime = String.format( Locale.getDefault(), "%02d:%02d:%02d", seconds / 3600, seconds % 3600 / 60, seconds % 60 ) } return standardTime } fun isEmpty(str: String?) = (str == null || str == "") } ================================================ FILE: src/jvmMain/kotlin/util/TimeUtil.kt ================================================ package util import java.text.SimpleDateFormat /** * Created by ssk on 2022/5/2. */ enum class FormatterEnum(val value: SimpleDateFormat) { YYYY_MM_DD(SimpleDateFormat("yyyy-MM-dd")), YYYYMMDD(SimpleDateFormat("yyyyMMdd")), YYYY_MM_DD__HH_MM(SimpleDateFormat("yyyy-MM-dd HH:mm")), YYYY_MM_DD__HH_MM_SS(SimpleDateFormat("yyyy-MM-dd HH:mm:ss")), YYYYMMDD__HH_MM(SimpleDateFormat("yyyyMMdd HH:mm")), YYYYMMDD__HH_MM_SS(SimpleDateFormat("yyyyMMdd HH:mm:ss")), HH_MM_SS(SimpleDateFormat("HH:mm:ss")) } object TimeUtil { fun parse(value: Long, formatter: FormatterEnum = FormatterEnum.YYYY_MM_DD): String { return formatter.value.format(value) } } ================================================ FILE: src/jvmMain/kotlin/util/createDataStore.kt ================================================ package util import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import base.AppConfig import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized import java.io.File private lateinit var dataStore: DataStore private val lock = SynchronizedObject() /** * Gets the singleton DataStore instance, creating it if necessary. */ fun getDataStore(): DataStore = synchronized(lock) { if (::dataStore.isInitialized) { dataStore } else { PreferenceDataStoreFactory.create { File("${AppConfig.cacheRootDir}/$dataStoreFileName") }.also { dataStore = it } } } internal const val dataStoreFileName = "NCMusicDesktop.preferences_pb" ================================================ FILE: src/jvmMain/resources/image/ic_empty.xml ================================================ ================================================ FILE: src/jvmMain/resources/image/ic_load_error.xml ================================================ ================================================ FILE: src/jvmMain/resources/image/ic_network_error.xml ================================================