Showing preview only (328K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<component name="ArtifactManager">
<artifact type="jar" name="NCMusicDesktop-jvm-1.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="NCMusicDesktop-jvm-1.0-SNAPSHOT.jar">
<element id="module-output" name="NCMusicDesktop.jvmMain" />
</root>
</artifact>
</component>
================================================
FILE: .idea/gradle.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="delegatedBuild" value="true" />
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="11" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
================================================
FILE: .idea/kotlinc.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="18" />
</component>
</project>
================================================
FILE: .idea/misc.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
================================================
FILE: .idea/uiDesigner.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>
================================================
FILE: .idea/vcs.xml
================================================
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
================================================
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年的回复是暂时没有计划,
到现在还是没有解决,凉飕飕~


### 第三方框架
- [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)
### 运行效果图






================================================
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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
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<T> = MutableStateFlow<ViewState<T>>
open class BaseViewModel : ViewModel() {
protected fun <T : BaseResult> launchFlow(
handleSuccessBlock: ((T) -> Unit)? = null,
handleFailBlock: ((code: Int?, message: String?) -> Unit)? = null,
judgeEmpty: ((T) -> Boolean)? = null,
call: suspend () -> T
): ViewStateMutableStateFlow<T> {
val flow = MutableStateFlow<ViewState<T>>(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 <T : BaseResult> 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<out T> {
object Loading : ViewState<Nothing>()
data class Success<T>(val data: T) : ViewState<T>()
object Empty : ViewState<Nothing>()
data class Fail(val errorCode: String, val errorMsg: String) : ViewState<Nothing>()
data class Error(val exception: Throwable) : ViewState<Nothing>()
}
================================================
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<SongBean>()
// 当前播放模式下的实际歌曲列表
var realSongList = mutableStateListOf<SongBean>()
var curSongBean by mutableStateOf<SongBean?>(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<SongBean>, 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<SongBean>()
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<LoginResult?> {
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<IPlayerListener>()
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 <T> getApi(retrofit: Class<T>): 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<CommentBean> = emptyList(),
val topComments: List<CommentBean> = emptyList(),
val hotComments: List<CommentBean> = 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<BeReplied>? = null,
var liked: Boolean = false,
) {
@Keep
data class Tag(
val datas: List<TagData>? = 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<CommentBean> = emptyList(),
)
@Keep
data class FloorCommentResult(
val data : FloorCommentData
) : BaseResult()
@Keep
data class FloorCommentData(
val totalCount: Int = 0,
val ownerComment: CommentBean,
val comments: List<CommentBean> = 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<NewSongBean>
) : BaseResult() {
override fun isEmpty() = data.isEmpty()
}
data class NewSongBean(
val name: String,
val artists: List<ArtistBean>,
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<SimplePlayListItem>
) : 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<Track>?,
val trackIds: List<TrackId>?,
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<Subscribers>,
val tags: List<String>,
) : Serializable {
fun convertToSimple() = SimplePlayListItem(id, name, coverImgUrl, description, trackUpdateTime, playCount, trackCount, subscribedCount, shareCount)
}
/**
* 歌单结果
*/
data class PlayListResult(
val playlists: List<PlaylistDetail>,
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<Ar>,
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<PlayListTab>) : 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<PlayListTab>,
val categories: Map<Int, String>) : BaseResult()
@Keep
data class PlaylistDetailResult(
val playlist: PlaylistDetail,
) : BaseResult()
/**
* 个人歌单
*/
@Keep
data class UserPlaylistResult(
val playlist: List<PlaylistDetail>,
) : BaseResult()
================================================
FILE: src/jvmMain/kotlin/model/PrivateContentResult.kt
================================================
package model
/**
* 独家放送
*/
data class PrivateContentResult(
val result: List<PrivateContentItem>
) : 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<RecommendMVItem>
) : 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<SongBean>) : BaseResult() {
override fun isEmpty() = songs.isEmpty()
}
@Keep
data class SongBean(
//歌曲id
val id: Long,
//歌曲名称
val name: String,
val al: Al,
val ar: List<Ar>,
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<SongUrl>)
@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<String>("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<String>,
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 <T> LazyListScope.ListToGridItems(
data: List<T>,
columns: Int,
itemContent: @Composable BoxScope.(index: Int, item: T) -> Unit
) {
val rows = (data.size + columns - 1) / columns
val groups = mutableListOf<MutableList<T>>()
for (row in 0 until rows) {
val group = mutableListOf<T>()
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 <T> ColumnScope.ListToGridItems(
data: List<T>,
columns: Int,
itemContent: @Composable BoxScope.(index: Int, item: T) -> Unit
) {
val rows = (data.size + columns - 1) / columns
val groups = mutableListOf<MutableList<T>>()
for (row in 0 until rows) {
val group = mutableListOf<T>()
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<Int>()
val rowHeights = mutableListOf<Int>()
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 <T> ViewStateComponent(
modifier: Modifier = Modifier,
key: String = UUID.randomUUID().toString(),
initFlow: ViewStateMutableStateFlow<T>? = null,
loadDataBlock: (() -> ViewStateMutableStateFlow<T>),
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<String, String>, loadDataBlock: () -> Unit) -> Unit)? = null,
contentView: @Composable BoxScope.(data: T) -> Unit
) {
val vm = viewModel(listOf(key)) { ViewStateComponentViewModel<T>() }
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<T>).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<T> : ViewModel() {
var flow: ViewStateMutableStateFlow<T>? = null
var reloadFlag by mutableStateOf(0)
private set
fun reload() {
reloadFlag++
}
}
fun getErrorMessagePair(exception: Throwable): Pair<String, String> {
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 <T> ViewState<T>.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 <T : IBasePagingBean> ViewStateLazyGridPagingComponent(
modifier: Modifier = Modifier,
key: String = UUID.randomUUID().toString(),
columns: Int,
pageSize: Int = 20,
loadDataBlock: (pageSize: Int, curPage: Int) -> ViewStateMutableStateFlow<T>,
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<String, String>, 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<T>() }
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<T>).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<T> : ViewModel() {
var flow: ViewStateMutableStateFlow<T>? = 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 <T> LazyListScope.handleListContent(
viewState: ViewState<T>?,
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<ToastMessage?>(null)
private val messageQueue = ConcurrentLinkedQueue<ToastMessage>()
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<PlayListResult>? = null
var lastTag = ""
fun getHighQualityPlayList(tag: String?): ViewStateMutableStateFlow<PlayListResult>? {
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<NewSongResult>?) {
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<ViewStateMutableStateFlow<NewSongResult>?>(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<Int>) {
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<Boolean>) {
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<Boolean>) {
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<Boolean>,
viewModel: PlayListTabSelectedBarViewModel,
category: String,
tabs: List<PlayListTab>
) {
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<Boolean>) {
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<Boolean>,
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<PlayListTab?>(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<String, MutableList<PlayListTab>> {
val categories = data.categories
val groupTabsMap = hashMapOf<String, MutableList<PlayListTab>>()
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<PrivateContentResult>?) {
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<ViewStateMutableStateFlow<PrivateContentResult>?>(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<RecommendPlayListResult>?,
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<ViewStateMutableStateFlow<RecommendPlayListResult>?>(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<RecommendMVResult>?) {
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<ViewStateMutableStateFlow<RecommendMVResult>?>(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<Boolean>) {
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<Boolean>) {
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<Int?>(null)
var qrcodeFile by mutableStateOf<File?>(null)
var getAccountInfoSuccess by mutableStateOf<Boolean?>(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<PlaylistDetail>) {
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<PlaylistDetail>? by mutableStateOf(null)
var collectPlayList: List<PlaylistDetail>? by mutableStateOf(null)
var selectedMenuTag: String? by mutableStateOf("发现音乐")
var selectedSongSheetTag: String? by mutableStateOf(null)
fun getUserPlayList(userId: Long) {
launch(handleSuccessBlock = {
val selfCreateList = mutableListOf<PlaylistDetail>()
val collectList = mutableListOf<PlaylistDetail>()
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).pad
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
Condensed preview — 107 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (324K chars).
[
{
"path": ".gitignore",
"chars": 539,
"preview": ".gradle\nbuild/\n!gradle/wrapper/gradle-wrapper.jar\n!**/src/main/**/build/\n!**/src/test/**/build/\n\n### IntelliJ IDEA ###\n."
},
{
"path": ".idea/.gitignore",
"chars": 47,
"preview": "# Default ignored files\n/shelf/\n/workspace.xml\n"
},
{
"path": ".idea/artifacts/NCMusicDesktop_jvm_1_0_SNAPSHOT.xml",
"chars": 326,
"preview": "<component name=\"ArtifactManager\">\n <artifact type=\"jar\" name=\"NCMusicDesktop-jvm-1.0-SNAPSHOT\">\n <output-path>$PROJ"
},
{
"path": ".idea/gradle.xml",
"chars": 732,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"GradleMigrationSettings\" migrationVersio"
},
{
"path": ".idea/kotlinc.xml",
"chars": 178,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"Kotlin2JvmCompilerArguments\">\n <optio"
},
{
"path": ".idea/misc.xml",
"chars": 339,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"ExternalStorageConfigurationManager\" ena"
},
{
"path": ".idea/uiDesigner.xml",
"chars": 8792,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"Palette2\">\n <group name=\"Swing\">\n "
},
{
"path": ".idea/vcs.xml",
"chars": 167,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"VcsDirectoryMappings\">\n <mapping dire"
},
{
"path": "README.md",
"chars": 2940,
"preview": "# NCMusicDesktop\n\n小明非常喜欢网易云,去年刚用Jetpack Compose写了个仿网易云app [NCMusic](https://github.com/sskEvan/NCMusic) ,最近发现compose-jb正"
},
{
"path": "build.gradle.kts",
"chars": 3190,
"preview": "import org.jetbrains.compose.compose\nimport org.jetbrains.compose.desktop.application.dsl.TargetFormat\n\nplugins {\n ko"
},
{
"path": "gradle/wrapper/gradle-wrapper.properties",
"chars": 202,
"preview": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributi"
},
{
"path": "gradle.properties",
"chars": 88,
"preview": "kotlin.code.style=official\nkotlin.version=1.7.20\nagp.version=7.3.0\ncompose.version=1.3.1"
},
{
"path": "gradlew",
"chars": 8047,
"preview": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"Lice"
},
{
"path": "gradlew.bat",
"chars": 2763,
"preview": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (th"
},
{
"path": "proguard-rules.pro",
"chars": 446,
"preview": "# DataStore 混淆\n-dontwarn androidx.datastore.**\n\n# Retrofit2 混淆\n-dontwarn javax.annotation.**\n-dontwarn javax.inject.**\n"
},
{
"path": "settings.gradle.kts",
"chars": 401,
"preview": "pluginManagement {\n repositories {\n google()\n gradlePluginPortal()\n mavenCentral()\n maven"
},
{
"path": "src/jvmMain/kotlin/Main.kt",
"chars": 1741,
"preview": "import androidx.compose.desktop.ui.tooling.preview.Preview\nimport androidx.compose.runtime.Composable\nimport androidx.co"
},
{
"path": "src/jvmMain/kotlin/base/AppConfig.kt",
"chars": 395,
"preview": "package base\n\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.window.WindowState\nimport java.io.File\n\nobje"
},
{
"path": "src/jvmMain/kotlin/base/BaseViewModel.kt",
"chars": 2604,
"preview": "package base\n\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.flow.MutableStateFlow\nimport kotlinx.coroutines.la"
},
{
"path": "src/jvmMain/kotlin/base/MusicPlayController.kt",
"chars": 6702,
"preview": "package base\n\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateListOf\nimport android"
},
{
"path": "src/jvmMain/kotlin/base/UserManager.kt",
"chars": 1203,
"preview": "package base\n\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.collectAsState\nimport com.google.gso"
},
{
"path": "src/jvmMain/kotlin/base/player/IPlayer.kt",
"chars": 374,
"preview": "package base.player\n\nimport model.SongBean\n\n\n/**\n * Created by ssk on 2022/4/23.\n */\ninterface IPlayer {\n fun setData"
},
{
"path": "src/jvmMain/kotlin/base/player/IPlayerListener.kt",
"chars": 215,
"preview": "package base.player\n\n\n/**\n * Created by ssk on 2022/4/23.\n */\ninterface IPlayerListener {\n fun onStatusChanged(status"
},
{
"path": "src/jvmMain/kotlin/base/player/NCPlayer.kt",
"chars": 6375,
"preview": "package base.player\n\nimport http.NCRetrofitClient\nimport kotlinx.coroutines.*\nimport model.SongBean\nimport uk.co.caprica"
},
{
"path": "src/jvmMain/kotlin/base/player/PlayMode.kt",
"chars": 152,
"preview": "package base.player\n\n/**\n * Created by ssk on 2022/4/23.\n */\nenum class PlayMode {\n // 单曲循环\n SINGLE,\n // 随机\n "
},
{
"path": "src/jvmMain/kotlin/base/player/PlayerStatus.kt",
"chars": 554,
"preview": "package base.player\n\n/**\n * Created by ssk on 2022/4/23.\n */\nsealed class PlayerStatus() {\n object IDLE: PlayerStatus"
},
{
"path": "src/jvmMain/kotlin/http/RetrofitClient.kt",
"chars": 1477,
"preview": "package http\n\nimport http.api.NCApi\nimport http.interceptor.CookieIntercept\nimport okhttp3.OkHttpClient\nimport retrofit2"
},
{
"path": "src/jvmMain/kotlin/http/api/NCApi.kt",
"chars": 3096,
"preview": "package http.api\n\nimport model.*\nimport retrofit2.http.GET\nimport retrofit2.http.Path\nimport retrofit2.http.Query\nimport"
},
{
"path": "src/jvmMain/kotlin/http/interceptor/CookieInterceptor.kt",
"chars": 915,
"preview": "package http.interceptor\n\nimport androidx.compose.runtime.collectAsState\nimport base.UserManager\nimport okhttp3.Intercep"
},
{
"path": "src/jvmMain/kotlin/model/BasePagingBean.kt",
"chars": 73,
"preview": "package model\n\ninterface IBasePagingBean {\n fun getTotalCount(): Int\n}"
},
{
"path": "src/jvmMain/kotlin/model/BaseResult.kt",
"chars": 311,
"preview": "package model\n\nimport androidx.annotation.Keep\nimport java.io.Serializable\n\n/**\n * Created by ssk on 2022/4/17.\n */\n@Kee"
},
{
"path": "src/jvmMain/kotlin/model/CommentResult.kt",
"chars": 1777,
"preview": "package model\n\nimport androidx.annotation.Keep\n\n@Keep\ndata class CommentResult(\n val isMusician: Boolean = false,\n "
},
{
"path": "src/jvmMain/kotlin/model/LoginResult.kt",
"chars": 1648,
"preview": "package model\n\nimport androidx.annotation.Keep\n\n@Keep\ndata class QrcodeKeyResult(val data: QrcodeKeyBean): BaseResult()\n"
},
{
"path": "src/jvmMain/kotlin/model/LyricResult.kt",
"chars": 1833,
"preview": "package model\n\nimport androidx.annotation.Keep\nimport model.BaseResult\n\n/**\n * Created by ssk on 2022/5/11.\n */\n@Keep\ncl"
},
{
"path": "src/jvmMain/kotlin/model/NewSongResult.kt",
"chars": 407,
"preview": "package model\n\n/**\n * 新歌速递\n */\ndata class NewSongResult(\n var data: List<NewSongBean>\n) : BaseResult() {\n override"
},
{
"path": "src/jvmMain/kotlin/model/PlayListResult.kt",
"chars": 2515,
"preview": "package model\n\nimport androidx.annotation.Keep\nimport java.io.Serializable\n\n/**\n * 推荐歌单结果\n */\ndata class RecommendPlayLi"
},
{
"path": "src/jvmMain/kotlin/model/PrivateContentResult.kt",
"chars": 384,
"preview": "package model\n\n\n/**\n * 独家放送\n */\ndata class PrivateContentResult(\n val result: List<PrivateContentItem>\n) : BaseResult"
},
{
"path": "src/jvmMain/kotlin/model/RecommendMVResult.kt",
"chars": 340,
"preview": "package model\n\n/**\n * 推荐MV\n */\ndata class RecommendMVResult(\n val result: List<RecommendMVItem>\n) : BaseResult() {\n "
},
{
"path": "src/jvmMain/kotlin/model/SongDetailResult.kt",
"chars": 748,
"preview": "package model\n\nimport androidx.annotation.Keep\n\n@Keep\ndata class SongDetailResult(val songs: List<SongBean>) : BaseResul"
},
{
"path": "src/jvmMain/kotlin/router/NavGraph.kt",
"chars": 2351,
"preview": "package router\n\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compo"
},
{
"path": "src/jvmMain/kotlin/router/RouterUrls.kt",
"chars": 730,
"preview": "package router\n\nobject RouterUrls {\n // 发现音乐\n const val DISCOVERY = \"discovery\"\n\n // 播客\n const val PODCAST ="
},
{
"path": "src/jvmMain/kotlin/ui/common/CommonImage.kt",
"chars": 3428,
"preview": "package ui.common\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.runtime.Composable\nimport androidx.c"
},
{
"path": "src/jvmMain/kotlin/ui/common/CommonTabLayout.kt",
"chars": 8300,
"preview": "package ui.common\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.gestures.detectTapGe"
},
{
"path": "src/jvmMain/kotlin/ui/common/CpnActionMore.kt",
"chars": 1028,
"preview": "package ui.common\n\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.material.Icon\nimport androidx.co"
},
{
"path": "src/jvmMain/kotlin/ui/common/ExpandableText.kt",
"chars": 3641,
"preview": "package ui.common\n\nimport androidx.compose.animation.animateContentSize\nimport androidx.compose.animation.core.Animatabl"
},
{
"path": "src/jvmMain/kotlin/ui/common/ListToGridItems.kt",
"chars": 2513,
"preview": "package ui.common\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyListScope\nimp"
},
{
"path": "src/jvmMain/kotlin/ui/common/LoadingComponent.kt",
"chars": 3611,
"preview": "package ui.common\n\nimport androidx.compose.animation.core.*\nimport androidx.compose.foundation.Canvas\nimport androidx.co"
},
{
"path": "src/jvmMain/kotlin/ui/common/ModifierExt.kt",
"chars": 808,
"preview": "package ui.common\n\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableIn"
},
{
"path": "src/jvmMain/kotlin/ui/common/NoSuccessComponent.kt",
"chars": 1656,
"preview": "package ui.common\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.material.Icon\nimport androidx.com"
},
{
"path": "src/jvmMain/kotlin/ui/common/PaingFooterNumBar.kt",
"chars": 5878,
"preview": "package ui.common\n\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.background\nimport "
},
{
"path": "src/jvmMain/kotlin/ui/common/SeekBar.kt",
"chars": 4088,
"preview": "package ui.common\n\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.layout.Box\nimport androi"
},
{
"path": "src/jvmMain/kotlin/ui/common/TableLayout.kt",
"chars": 1939,
"preview": "package ui.common\n\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Modifier\nimport androidx.compos"
},
{
"path": "src/jvmMain/kotlin/ui/common/ViewStateComponent.kt",
"chars": 6111,
"preview": "package ui.common\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.runtime.*\nimport androidx.compose"
},
{
"path": "src/jvmMain/kotlin/ui/common/ViewStateLazyGridPagingComponent.kt",
"chars": 11930,
"preview": "package ui.common\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyListScope\nimp"
},
{
"path": "src/jvmMain/kotlin/ui/common/toast/Toast.kt",
"chars": 2654,
"preview": "package ui.common.toast\n\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.background\ni"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/DiscoveryPage.kt",
"chars": 1678,
"preview": "package ui.discovery\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.height\n"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnHighQualityPlayListEntrance.kt",
"chars": 4470,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.background\n"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnNewSongEntrance.kt",
"chars": 3832,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyListSc"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnPersonalRecommend.kt",
"chars": 2576,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.lazy.Lazy"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnPlayListItem.kt",
"chars": 3037,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.Image\nimport ui.common.onClick\nimport androidx.compose.foun"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnPlayListTabSelectedBar.kt",
"chars": 8895,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.*\nimport androidx.compose.foundation.layout.*\nimport androi"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnPrivateContentEntrance.kt",
"chars": 3484,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.layout.*\nimport an"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnRecommandPlayListEntrance.kt",
"chars": 4304,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.Image\nimport ui.common.onClick\nimport androidx.compose.foun"
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnRecommendPlayList.kt",
"chars": 1788,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.runtime.*\nimport androidx."
},
{
"path": "src/jvmMain/kotlin/ui/discovery/cpn/CpnRecommentMVEntrance.kt",
"chars": 4733,
"preview": "package ui.discovery.cpn\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.foundation.Image\n"
},
{
"path": "src/jvmMain/kotlin/ui/login/QrcodeLoginDialog.kt",
"chars": 9725,
"preview": "package ui.login\n\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.tween\nimport "
},
{
"path": "src/jvmMain/kotlin/ui/main/MainPage.kt",
"chars": 759,
"preview": "package ui.main\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.runtime.Composable\nimport androidx."
},
{
"path": "src/jvmMain/kotlin/ui/main/cpn/CpnMainLeftMenu.kt",
"chars": 12227,
"preview": "package ui.main.cpn\n\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.foundation.*\nimport andro"
},
{
"path": "src/jvmMain/kotlin/ui/main/cpn/CpnMainMusicPlayDrawer.kt",
"chars": 3405,
"preview": "package ui.main.cpn\n\nimport androidx.compose.animation.*\nimport androidx.compose.foundation.background\nimport ui.common."
},
{
"path": "src/jvmMain/kotlin/ui/main/cpn/CpnMainRightContainer.kt",
"chars": 4588,
"preview": "package ui.main.cpn\n\nimport androidx.compose.foundation.background\nimport ui.common.onClick\nimport androidx.compose.foun"
},
{
"path": "src/jvmMain/kotlin/ui/main/cpn/CpnMusicPlayBottomBar.kt",
"chars": 7211,
"preview": "package ui.main.cpn\n\nimport ui.common.SeekBar\nimport androidx.compose.foundation.background\nimport ui.common.onClick\nimp"
},
{
"path": "src/jvmMain/kotlin/ui/main/cpn/CpnPlaformDecoratedButtons.kt",
"chars": 3169,
"preview": "package ui.main.cpn\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Box\nimport "
},
{
"path": "src/jvmMain/kotlin/ui/main/cpn/CpnThemePopup.kt",
"chars": 3396,
"preview": "package ui.main.cpn\n\nimport androidx.compose.foundation.background\nimport ui.common.onClick\nimport androidx.compose.foun"
},
{
"path": "src/jvmMain/kotlin/ui/play/CpnCurrentPlayListSheet.kt",
"chars": 6804,
"preview": "package ui.play\n\nimport androidx.compose.animation.core.tween\nimport androidx.compose.foundation.ExperimentalFoundationA"
},
{
"path": "src/jvmMain/kotlin/ui/play/CpnLyric.kt",
"chars": 7380,
"preview": "package ui.play\n\nimport ui.common.onClick\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation"
},
{
"path": "src/jvmMain/kotlin/ui/play/CpnMusicPlay.kt",
"chars": 5540,
"preview": "package ui.play\n\nimport androidx.compose.animation.core.*\nimport androidx.compose.foundation.Image\nimport androidx.compo"
},
{
"path": "src/jvmMain/kotlin/ui/play/CpnSongInfo.kt",
"chars": 1381,
"preview": "package ui.play\n\nimport androidx.compose.foundation.layout.ColumnScope\nimport androidx.compose.foundation.layout.Row\nimp"
},
{
"path": "src/jvmMain/kotlin/ui/playlist/PlayListDetailPage.kt",
"chars": 12550,
"preview": "package ui.playlist\n\nimport androidx.compose.foundation.*\nimport androidx.compose.foundation.layout.*\nimport androidx.co"
},
{
"path": "src/jvmMain/kotlin/ui/playlist/cpn/CpnPlayListCommentList.kt",
"chars": 5214,
"preview": "package ui.playlist.cpn\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyListSco"
},
{
"path": "src/jvmMain/kotlin/ui/playlist/cpn/CpnPlayListSubscribers.kt",
"chars": 2078,
"preview": "package ui.playlist.cpn\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.foundation.lazy.LazyListSco"
},
{
"path": "src/jvmMain/kotlin/ui/playlist/cpn/CpnTrackList.kt",
"chars": 6800,
"preview": "package ui.playlist.cpn\n\nimport androidx.compose.foundation.background\nimport ui.common.onClick\nimport androidx.compose."
},
{
"path": "src/jvmMain/kotlin/ui/setting/SettingPage.kt",
"chars": 1080,
"preview": "package ui.setting\n\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSi"
},
{
"path": "src/jvmMain/kotlin/ui/theme/Shape.kt",
"chars": 298,
"preview": "package ui.common.theme\n\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.Sh"
},
{
"path": "src/jvmMain/kotlin/ui/theme/Theme.kt",
"chars": 3585,
"preview": "package ui.common.theme\n\nimport androidx.compose.animation.animateColorAsState\nimport androidx.compose.animation.core.Tw"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/AppColors.kt",
"chars": 1639,
"preview": "package ui.common.theme.color\n\nimport androidx.compose.runtime.Stable\nimport androidx.compose.runtime.getValue\nimport an"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/dark/DartColorPalette.kt",
"chars": 675,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n\n/"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/light/BlueColorPalette.kt",
"chars": 679,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n\n/"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/light/DefaultColorPalette.kt",
"chars": 677,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n/*"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/light/GreenColorPalette.kt",
"chars": 680,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n\n/"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/light/OriginColorPalette.kt",
"chars": 677,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n\n/"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/light/PurpleColorPalette.kt",
"chars": 676,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n/*"
},
{
"path": "src/jvmMain/kotlin/ui/theme/color/palette/light/YellowColorPalette.kt",
"chars": 681,
"preview": "package ui.common.theme.color.light\n\nimport androidx.compose.ui.graphics.Color\nimport ui.common.theme.color.AppColors\n\n/"
},
{
"path": "src/jvmMain/kotlin/ui/todo/TestPage.kt",
"chars": 218,
"preview": "package ui.todo\n\nimport androidx.compose.foundation.layout.*\nimport androidx.compose.runtime.*\nimport androidx.compose.u"
},
{
"path": "src/jvmMain/kotlin/ui/todo/TodoPage.kt",
"chars": 829,
"preview": "package ui.todo\n\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Box\nimport andr"
},
{
"path": "src/jvmMain/kotlin/util/DataStoreUtils.kt",
"chars": 7646,
"preview": "package util\n\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.preferences.core.*\nimport kotlinx.corou"
},
{
"path": "src/jvmMain/kotlin/util/DensityExt.kt",
"chars": 215,
"preview": "package util\n\nimport androidx.compose.ui.unit.Density\nimport androidx.compose.ui.unit.Dp\nimport androidx.compose.ui.unit"
},
{
"path": "src/jvmMain/kotlin/util/EnvUtil.kt",
"chars": 328,
"preview": "package util\n\nimport java.util.*\n\nobject EnvUtil {\n val osName = System.getProperty(\"os.name\", \"generic\")\n\n fun is"
},
{
"path": "src/jvmMain/kotlin/util/LyricUtil.kt",
"chars": 5079,
"preview": "package util\n\n\nimport model.LyricResult\nimport ui.play.LyricModel\nimport java.util.*\nimport java.util.regex.Matcher\nimpo"
},
{
"path": "src/jvmMain/kotlin/util/QrcodeUtil.kt",
"chars": 1570,
"preview": "package util\n\nimport base.AppConfig\nimport com.google.zxing.BarcodeFormat\nimport com.google.zxing.EncodeHintType\nimport "
},
{
"path": "src/jvmMain/kotlin/util/StringUtil.kt",
"chars": 1538,
"preview": "package util\n\nimport java.util.*\n\n/**\n * Created by ssk on 2022/4/23.\n */\nobject StringUtil {\n\n fun friendlyNumber(nu"
},
{
"path": "src/jvmMain/kotlin/util/TimeUtil.kt",
"chars": 690,
"preview": "package util\n\nimport java.text.SimpleDateFormat\n\n/**\n * Created by ssk on 2022/5/2.\n */\nenum class FormatterEnum(val val"
},
{
"path": "src/jvmMain/kotlin/util/createDataStore.kt",
"chars": 880,
"preview": "package util\n\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.preferences.core.PreferenceDataStoreFac"
},
{
"path": "src/jvmMain/resources/image/ic_empty.xml",
"chars": 719,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"32dp\"\n android:height=\"32dp\"\n "
},
{
"path": "src/jvmMain/resources/image/ic_load_error.xml",
"chars": 3120,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"43dp\"\n android:height=\"32dp\"\n "
},
{
"path": "src/jvmMain/resources/image/ic_network_error.xml",
"chars": 8296,
"preview": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n android:width=\"211.6875dp\"\n android:height=\"19"
}
]
// ... and 2 more files (download for full content)
About this extraction
This page contains the full source code of the sskEvan/NCMusicDesktop GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 107 files (292.2 KB), approximately 75.4k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.