├── .gitignore ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── image ├── icon.ai └── screenshot │ └── HistoryScreen.png ├── settings.gradle.kts └── src └── main ├── kotlin ├── Main.kt ├── bean │ └── MusicCoverBean.kt ├── config │ └── Config.kt ├── db │ └── DatabaseManager.kt ├── player │ ├── Player.kt │ └── SwitchMusicProxy.kt ├── ui │ ├── BodyContentType.kt │ ├── component │ │ ├── AsyncImage.kt │ │ ├── Dialog.kt │ │ ├── UPlayerTopBar.kt │ │ ├── controller │ │ │ └── MusicController.kt │ │ ├── lazyverticalgrid │ │ │ ├── AnimeItemSpace.kt │ │ │ ├── AnimeShowSpan.kt │ │ │ ├── LazyGridAdapter.kt │ │ │ ├── UPlayerLazyVerticalGrid.kt │ │ │ └── proxy │ │ │ │ ├── History1Proxy.kt │ │ │ │ └── MusicCover1Proxy.kt │ │ └── sidebar │ │ │ └── SideBar.kt │ ├── history │ │ └── HistoryScreen.kt │ ├── main │ │ └── MainScreen.kt │ └── playlist │ │ └── PlayListScreen.kt └── util │ ├── Dialog.kt │ ├── ImageUtil.kt │ ├── PointerEventUtil.kt │ └── TimeUtil.kt ├── resources └── image │ └── icon.svg └── sqldelight └── com └── skyd └── db └── History.sq /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | /Database/ 7 | 8 | ### IntelliJ IDEA ### 9 | .idea/ 10 | *.iws 11 | *.iml 12 | *.ipr 13 | out/ 14 | !**/src/main/**/out/ 15 | !**/src/test/**/out/ 16 | 17 | ### Eclipse ### 18 | .apt_generated 19 | .classpath 20 | .factorypath 21 | .project 22 | .settings 23 | .springBeans 24 | .sts4-cache 25 | bin/ 26 | !**/src/main/**/bin/ 27 | !**/src/test/**/bin/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | 39 | ### Mac OS ### 40 | .DS_Store 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎵UPlayer 2 | 3 | 一个使用[Compose Multiplatform](https://github.com/JetBrains/compose-jb)开发的桌面本地音乐播放器 4 | 5 | ## 🏗︎正在开发中的界面 6 | 7 | ![HistoryScreen](image/screenshot/HistoryScreen.png) -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.compose.compose 2 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat 3 | 4 | buildscript { 5 | dependencies { 6 | classpath("com.guardsquare:proguard-gradle:7.2.1") 7 | classpath("com.squareup.sqldelight:gradle-plugin:1.5.3") 8 | } 9 | } 10 | 11 | plugins { 12 | kotlin("jvm") 13 | id("org.jetbrains.compose") 14 | id("com.squareup.sqldelight") version "1.5.3" 15 | } 16 | 17 | repositories { 18 | google() 19 | mavenCentral() 20 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 21 | maven("https://oss.sonatype.org/content/repositories/snapshots") 22 | } 23 | 24 | dependencies { 25 | implementation(compose.desktop.currentOs) 26 | @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) 27 | implementation(compose.material3) 28 | implementation(compose.materialIconsExtended) 29 | implementation("uk.co.caprica:vlcj:4.7.3") 30 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.6.2") 31 | implementation("com.squareup.sqldelight:sqlite-driver:1.5.3") 32 | } 33 | 34 | compose.desktop { 35 | application { 36 | mainClass = "MainKt" 37 | 38 | nativeDistributions { 39 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) 40 | packageName = "UPlayer" 41 | packageVersion = "1.0.0" 42 | 43 | windows { 44 | menu = true 45 | } 46 | } 47 | } 48 | } 49 | 50 | sqldelight { 51 | database(name = "AppDatabase") { 52 | packageName = "com.skyd.db" 53 | } 54 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | kotlin.version=1.6.10 3 | agp.version=4.2.2 4 | compose.version=1.1.0 5 | 6 | # clash?? 7 | systemProp.socks.proxyHost=127.0.0.1 8 | systemProp.socks.proxyPort=7890 9 | 10 | systemProp.https.proxyPort=7890 11 | systemProp.http.proxyHost=127.0.0.1 12 | systemProp.https.proxyHost=127.0.0.1 13 | systemProp.http.proxyPort=7890 -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyD666/UPlayer/22858956b1d5f70dabceb1df2aeaf61bcfd6e643/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /image/icon.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyD666/UPlayer/22858956b1d5f70dabceb1df2aeaf61bcfd6e643/image/icon.ai -------------------------------------------------------------------------------- /image/screenshot/HistoryScreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyD666/UPlayer/22858956b1d5f70dabceb1df2aeaf61bcfd6e643/image/screenshot/HistoryScreen.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | // Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. 2 | pluginManagement { 3 | repositories { 4 | google() 5 | gradlePluginPortal() 6 | mavenCentral() 7 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 8 | maven("https://oss.sonatype.org/content/repositories/snapshots") 9 | } 10 | 11 | plugins { 12 | kotlin("jvm").version(extra["kotlin.version"] as String) 13 | id("org.jetbrains.compose").version("1.2.0-alpha01-dev620") 14 | id("com.squareup.sqldelight") 15 | } 16 | } 17 | 18 | rootProject.name = "UPlayer" 19 | 20 | -------------------------------------------------------------------------------- /src/main/kotlin/Main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.material3.MaterialTheme 2 | import androidx.compose.runtime.* 3 | import androidx.compose.ui.Alignment 4 | import androidx.compose.ui.platform.LocalDensity 5 | import androidx.compose.ui.res.loadSvgPainter 6 | import androidx.compose.ui.res.useResource 7 | import androidx.compose.ui.unit.DpSize 8 | import androidx.compose.ui.unit.dp 9 | import androidx.compose.ui.window.* 10 | import config.Config 11 | import player.Player 12 | import ui.main.MainScreen 13 | import javax.swing.UIManager 14 | 15 | fun main() { 16 | System.setProperty("compose.application.configure.swing.globals", "true") 17 | application { 18 | LaunchedEffect(Unit) { 19 | UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()) 20 | } 21 | val appIcon = useResource("image/icon.svg") { 22 | loadSvgPainter(inputStream = it, density = LocalDensity.current) 23 | } 24 | var windowVisible by remember { mutableStateOf(true) } 25 | MaterialTheme { 26 | val trayState = rememberTrayState() 27 | val windowState = rememberWindowState( 28 | position = WindowPosition.Aligned(Alignment.Center), 29 | size = DpSize(1000.dp, 700.dp) 30 | ) 31 | 32 | Tray( 33 | state = trayState, 34 | icon = appIcon, 35 | onAction = { windowVisible = true }, 36 | menu = { 37 | Item( 38 | text = "显示", 39 | onClick = { windowVisible = true } 40 | ) 41 | Item( 42 | text = "退出", 43 | onClick = ::exitApplication 44 | ) 45 | } 46 | ) 47 | 48 | Window( 49 | state = windowState, 50 | title = Config.APP_NAME, 51 | icon = appIcon, 52 | visible = windowVisible, 53 | onCloseRequest = { windowVisible = false } 54 | ) { 55 | MainScreen() 56 | } 57 | } 58 | } 59 | 60 | Player.release() 61 | } -------------------------------------------------------------------------------- /src/main/kotlin/bean/MusicCoverBean.kt: -------------------------------------------------------------------------------- 1 | package bean 2 | 3 | data class MusicCover1Bean(var path: String) -------------------------------------------------------------------------------- /src/main/kotlin/config/Config.kt: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import java.io.File 4 | 5 | object Config { 6 | const val APP_NAME = "UPlayer" 7 | 8 | val currentPath = File("").absoluteFile.path 9 | } -------------------------------------------------------------------------------- /src/main/kotlin/db/DatabaseManager.kt: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import com.skyd.db.AppDatabase 4 | import com.squareup.sqldelight.db.SqlDriver 5 | import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver 6 | import config.Config.currentPath 7 | import java.io.File 8 | 9 | object DatabaseManager { 10 | private lateinit var instance: AppDatabase 11 | internal fun getInstance(): AppDatabase { 12 | if (this::instance.isInitialized) return instance 13 | else instance = AppDatabase(createDriver()) 14 | return instance 15 | } 16 | 17 | fun createDriver(): SqlDriver { 18 | val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY + getDbPath()) 19 | AppDatabase.Schema.create(driver) 20 | return driver 21 | } 22 | 23 | val dbName = "AppDatabase.db" 24 | val directory = currentPath.let { 25 | if (it.endsWith("\\")) "${it}Database\\" 26 | else "$it/Database/" 27 | } 28 | 29 | private fun getDbPath(): String { 30 | val directory = File(directory) 31 | if (!directory.exists()) { 32 | directory.mkdirs() 33 | } 34 | val db = File(directory, dbName) 35 | if (!db.exists()) { 36 | db.createNewFile() 37 | } 38 | return db.path 39 | } 40 | } 41 | 42 | val appDatabase = DatabaseManager.getInstance() 43 | -------------------------------------------------------------------------------- /src/main/kotlin/player/Player.kt: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.compose.runtime.setValue 6 | import bean.MusicCover1Bean 7 | import db.appDatabase 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.launch 11 | import ui.history.updateHistoryListWithSort 12 | import uk.co.caprica.vlcj.factory.MediaPlayerFactory 13 | import uk.co.caprica.vlcj.media.Media 14 | import uk.co.caprica.vlcj.media.MediaEventAdapter 15 | import uk.co.caprica.vlcj.media.Meta 16 | import uk.co.caprica.vlcj.media.MetaData 17 | import uk.co.caprica.vlcj.player.base.MediaPlayer 18 | import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter 19 | import uk.co.caprica.vlcj.player.base.State 20 | import java.io.File 21 | import java.lang.Integer.max 22 | import java.nio.charset.Charset 23 | import kotlin.math.min 24 | 25 | object Player { 26 | private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default) 27 | 28 | private val player by lazy { MediaPlayerFactory().mediaPlayers().newMediaPlayer() } 29 | 30 | var switchMusicProxy: SwitchMusicProxy by mutableStateOf(SwitchMusicProxy.Once) 31 | 32 | var musicList: MutableList = mutableListOf() 33 | 34 | var musicIndex: Int = 0 35 | 36 | var playState by mutableStateOf(player.status().state()) 37 | private set 38 | 39 | suspend fun prepare(mrl: String): Boolean { 40 | appDatabase.historyQueries.insertOrUpdateHistory( 41 | playTimestamp = System.currentTimeMillis(), 42 | path = mrl 43 | ) 44 | updateHistoryListWithSort(appDatabase.historyQueries.getAllHistory().executeAsList()) 45 | musicList.forEachIndexed { index, s -> if (s.path == mrl) musicIndex = index } 46 | return player.media().prepare(mrl) 47 | } 48 | 49 | fun release() { 50 | player.release() 51 | } 52 | 53 | fun pause() { 54 | player.controls().pause() 55 | } 56 | 57 | fun play() { 58 | player.controls().play() 59 | } 60 | 61 | fun setPosition(position: Float) { 62 | player.controls().setPosition(position) 63 | } 64 | 65 | fun previousOrLast() { 66 | if (musicList.isEmpty()) return 67 | scope.launch { 68 | musicIndex = (musicIndex - 1) % musicList.size 69 | prepare(musicList[musicIndex].path) 70 | play() 71 | } 72 | } 73 | 74 | fun previous() { 75 | if (musicList.isEmpty()) return 76 | scope.launch { 77 | musicIndex = max(musicIndex - 1, 0) 78 | prepare(musicList[musicIndex].path) 79 | play() 80 | } 81 | } 82 | 83 | fun next() { 84 | if (musicList.isEmpty()) return 85 | scope.launch { 86 | musicIndex = min(musicIndex + 1, musicList.size - 1) 87 | prepare(musicList[musicIndex].path) 88 | play() 89 | } 90 | } 91 | 92 | fun nextOrFirst() { 93 | if (musicList.isEmpty()) return 94 | scope.launch { 95 | musicIndex = (musicIndex + 1) % musicList.size 96 | prepare(musicList[musicIndex].path) 97 | play() 98 | } 99 | } 100 | 101 | var volume: Int = player.audio().volume() 102 | set(value) { 103 | player.audio().setVolume(min(200, max(0, value))) 104 | } 105 | 106 | var isMute: Boolean = player.audio().isMute 107 | set(value) { 108 | player.audio().isMute = value 109 | field = value 110 | } 111 | 112 | var metaData: MetaData? by mutableStateOf(null) 113 | private set 114 | 115 | var duration: Long by mutableStateOf(player.media().info()?.duration() ?: 0L) 116 | private set 117 | 118 | private val _onPositionChanged: (MediaPlayer?, Float) -> Unit = { player, newPosition -> 119 | onPositionChangedList.forEach { 120 | it.invoke(player, newPosition) 121 | } 122 | } 123 | 124 | private var onPositionChangedList: MutableList<(MediaPlayer?, Float) -> Unit> = 125 | mutableListOf() 126 | 127 | fun addOnPositionChangedListener(listener: (MediaPlayer?, Float) -> Unit) { 128 | onPositionChangedList.add(listener) 129 | } 130 | 131 | fun removeOnPositionChangedListener(listener: (MediaPlayer?, Float) -> Unit) { 132 | onPositionChangedList.remove(listener) 133 | } 134 | 135 | private val _onTimeChanged: (MediaPlayer?, Long) -> Unit = { player, newTime -> 136 | onTimeChangedList.forEach { 137 | it.invoke(player, newTime) 138 | } 139 | } 140 | private var onTimeChangedList: MutableList<(MediaPlayer?, Long) -> Unit> = mutableListOf() 141 | 142 | fun addOnTimeChangedListener(listener: (MediaPlayer?, Long) -> Unit) { 143 | onTimeChangedList.add(listener) 144 | } 145 | 146 | fun removeOnTimeChangedListener(listener: (MediaPlayer?, Long) -> Unit) { 147 | onTimeChangedList.remove(listener) 148 | } 149 | 150 | private fun getMusicTitle(): String { 151 | runCatching { 152 | val rawTitle = player.media().meta()?.get(Meta.TITLE).orEmpty() 153 | val encodedTitle = String(rawTitle.toByteArray(Charset.forName("ASCII")), Charsets.UTF_8) 154 | println(rawTitle.toByteArray(Charset.forName("ASCII"))) 155 | return encodedTitle.ifBlank { 156 | val filePath = player.media().info()?.mrl() 157 | if (filePath.isNullOrBlank()) "" else File(filePath).name 158 | } 159 | }.onFailure { 160 | it.printStackTrace() 161 | } 162 | return "" 163 | } 164 | 165 | init { 166 | musicList = appDatabase.historyQueries.getAllHistory().executeAsList() 167 | .sortedBy { -it.playTimestamp }.map { MusicCover1Bean(path = it.path) }.toMutableList() 168 | 169 | if (musicList.isNotEmpty()) { 170 | scope.launch { prepare(musicList[0].path) } 171 | } 172 | 173 | player.events().addMediaPlayerEventListener(object : MediaPlayerEventAdapter() { 174 | override fun positionChanged(mediaPlayer: MediaPlayer?, newPosition: Float) { 175 | _onPositionChanged.invoke(mediaPlayer, newPosition) 176 | } 177 | 178 | override fun timeChanged(mediaPlayer: MediaPlayer?, newTime: Long) { 179 | _onTimeChanged.invoke(mediaPlayer, newTime) 180 | } 181 | 182 | override fun finished(mediaPlayer: MediaPlayer?) { 183 | when (switchMusicProxy) { 184 | SwitchMusicProxy.Once -> {} 185 | SwitchMusicProxy.RepeatOne -> scope.launch { 186 | prepare(musicList[musicIndex].path) 187 | play() 188 | } 189 | SwitchMusicProxy.Next -> next() 190 | SwitchMusicProxy.ListLoop -> nextOrFirst() 191 | } 192 | } 193 | }) 194 | player.events().addMediaEventListener(object : MediaEventAdapter() { 195 | override fun mediaStateChanged(media: Media?, newState: State) { 196 | playState = newState 197 | if (newState == State.PLAYING) { 198 | duration = player.media().info()?.duration() ?: 0L 199 | metaData = player.media().meta()?.asMetaData() 200 | } 201 | } 202 | 203 | override fun mediaMetaChanged(media: Media?, metaType: Meta?) { 204 | metaData = player.media().meta()?.asMetaData() 205 | } 206 | }) 207 | } 208 | } -------------------------------------------------------------------------------- /src/main/kotlin/player/SwitchMusicProxy.kt: -------------------------------------------------------------------------------- 1 | package player 2 | 3 | enum class SwitchMusicProxy { 4 | Once, RepeatOne, Next, ListLoop 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/BodyContentType.kt: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | enum class BodyContentType { 4 | Home, SongList, PlayList, History, MusicDetail 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/AsyncImage.kt: -------------------------------------------------------------------------------- 1 | package ui.component 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.produceState 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.painter.Painter 9 | import androidx.compose.ui.layout.ContentScale 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.withContext 12 | import java.io.IOException 13 | 14 | @Composable 15 | fun AsyncImage( 16 | load: suspend () -> T, 17 | painterFor: @Composable (T) -> Painter, 18 | contentDescription: String? = null, 19 | modifier: Modifier = Modifier, 20 | contentScale: ContentScale = ContentScale.Fit, 21 | ) { 22 | val image: T? by produceState(null) { 23 | value = withContext(Dispatchers.IO) { 24 | try { 25 | load() 26 | } catch (e: IOException) { 27 | // instead of printing to console, you can also write this to log, 28 | // or show some error placeholder 29 | e.printStackTrace() 30 | null 31 | } 32 | } 33 | } 34 | 35 | if (image != null) { 36 | Image( 37 | painter = painterFor(image!!), 38 | contentDescription = contentDescription, 39 | contentScale = contentScale, 40 | modifier = modifier 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/Dialog.kt: -------------------------------------------------------------------------------- 1 | package ui.component 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.widthIn 5 | import androidx.compose.foundation.layout.wrapContentWidth 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material.AlertDialog 9 | import androidx.compose.material.ExperimentalMaterialApi 10 | import androidx.compose.material3.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.painter.Painter 14 | import androidx.compose.ui.graphics.vector.ImageVector 15 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 16 | import androidx.compose.ui.unit.dp 17 | 18 | @Composable 19 | fun MessageDialog( 20 | title: String = "警告", 21 | message: String, 22 | icon: ImageVector? = null, 23 | negativeText: String = "取消", 24 | positiveText: String = "确定", 25 | onDismissRequest: (() -> Unit)? = null, 26 | onNegative: (() -> Unit)? = null, 27 | onPositive: (() -> Unit)? = null, 28 | ) { 29 | MessageDialog( 30 | title = title, 31 | message = message, 32 | icon = if (icon == null) null else rememberVectorPainter(icon), 33 | negativeText = negativeText, 34 | positiveText = positiveText, 35 | onDismissRequest = onDismissRequest, 36 | onNegative = onNegative, 37 | onPositive = onPositive 38 | ) 39 | } 40 | 41 | @OptIn(ExperimentalMaterialApi::class) 42 | @Composable 43 | fun MessageDialog( 44 | title: String = "警告", 45 | message: String, 46 | icon: Painter? = null, 47 | negativeText: String = "取消", 48 | positiveText: String = "确定", 49 | onDismissRequest: (() -> Unit)? = null, 50 | onNegative: (() -> Unit)? = null, 51 | onPositive: (() -> Unit)? = null, 52 | ) { 53 | val dismissButton: @Composable (() -> Unit) = { 54 | TextButton( 55 | onClick = { 56 | onNegative?.invoke() 57 | } 58 | ) { 59 | Text(text = negativeText) 60 | } 61 | } 62 | val iconLambda: @Composable (() -> Unit)? = if (icon != null) { 63 | { Icon(painter = icon, contentDescription = null) } 64 | } else null 65 | AlertDialog( 66 | // icon = if (icon != null) iconLambda else null, 67 | title = { 68 | Text(text = title) 69 | }, 70 | text = { 71 | Text( 72 | modifier = Modifier.widthIn(min = 250.dp), 73 | text = message 74 | ) 75 | }, 76 | confirmButton = { 77 | TextButton( 78 | onClick = { 79 | onPositive?.invoke() 80 | } 81 | ) { 82 | Text(text = positiveText) 83 | } 84 | }, 85 | dismissButton = if (onNegative == null) null else dismissButton, 86 | onDismissRequest = { 87 | onDismissRequest?.invoke() 88 | } 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/UPlayerTopBar.kt: -------------------------------------------------------------------------------- 1 | package ui.component 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.rounded.ArrowBack 6 | import androidx.compose.material3.* 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.painter.Painter 12 | import androidx.compose.ui.graphics.vector.ImageVector 13 | import androidx.compose.ui.unit.dp 14 | 15 | enum class AnimeTopBarStyle { 16 | Small, Large 17 | } 18 | 19 | @Composable 20 | fun UPlayerTopBar( 21 | modifier: Modifier = Modifier, 22 | style: AnimeTopBarStyle = AnimeTopBarStyle.Small, 23 | title: @Composable () -> Unit, 24 | contentPadding: @Composable () -> PaddingValues = { PaddingValues() }, 25 | navigationIcon: @Composable () -> Unit = {}, 26 | actions: @Composable RowScope.() -> Unit = {}, 27 | scrollBehavior: TopAppBarScrollBehavior? = null, 28 | ) { 29 | val colors = when (style) { 30 | AnimeTopBarStyle.Small -> TopAppBarDefaults.smallTopAppBarColors() 31 | AnimeTopBarStyle.Large -> TopAppBarDefaults.largeTopAppBarColors() 32 | } 33 | val scrollFraction = scrollBehavior?.scrollFraction ?: 0f 34 | val appBarContainerColor by colors.containerColor(scrollFraction) 35 | val topBarModifier = Modifier.padding(contentPadding()) 36 | Surface(modifier = modifier, color = appBarContainerColor) { 37 | when (style) { 38 | AnimeTopBarStyle.Small -> { 39 | SmallTopAppBar( 40 | modifier = topBarModifier, 41 | title = title, 42 | navigationIcon = navigationIcon, 43 | actions = actions, 44 | colors = colors, 45 | scrollBehavior = scrollBehavior 46 | ) 47 | } 48 | AnimeTopBarStyle.Large -> { 49 | LargeTopAppBar( 50 | modifier = topBarModifier, 51 | title = title, 52 | navigationIcon = navigationIcon, 53 | actions = actions, 54 | colors = colors, 55 | scrollBehavior = scrollBehavior 56 | ) 57 | } 58 | } 59 | } 60 | } 61 | 62 | @Composable 63 | fun TopBarIcon( 64 | painter: Painter, 65 | modifier: Modifier = Modifier, 66 | onClick: () -> Unit = {}, 67 | tint: Color = LocalContentColor.current, 68 | contentDescription: String?, 69 | ) { 70 | IconButton(onClick = onClick) { 71 | Icon( 72 | modifier = modifier.size(24.dp), 73 | painter = painter, 74 | tint = tint, 75 | contentDescription = contentDescription 76 | ) 77 | } 78 | } 79 | 80 | @Composable 81 | fun TopBarIcon( 82 | imageVector: ImageVector, 83 | modifier: Modifier = Modifier, 84 | onClick: () -> Unit = {}, 85 | tint: Color = LocalContentColor.current, 86 | contentDescription: String?, 87 | ) { 88 | IconButton(onClick = onClick) { 89 | Icon( 90 | modifier = modifier.size(24.dp), 91 | imageVector = imageVector, 92 | tint = tint, 93 | contentDescription = contentDescription 94 | ) 95 | } 96 | } 97 | 98 | @Composable 99 | fun BackIcon(onClick: () -> Unit = {}) { 100 | TopBarIcon( 101 | imageVector = Icons.Rounded.ArrowBack, 102 | contentDescription = "返回", 103 | onClick = onClick 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/controller/MusicController.kt: -------------------------------------------------------------------------------- 1 | package ui.component.controller 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.CircularProgressIndicator 5 | import androidx.compose.material.Icon 6 | import androidx.compose.material.IconButton 7 | import androidx.compose.material.Slider 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.outlined.LooksOne 10 | import androidx.compose.material.icons.rounded.* 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.* 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.graphics.ImageBitmap 17 | import androidx.compose.ui.graphics.toAwtImage 18 | import androidx.compose.ui.graphics.toPainter 19 | import androidx.compose.ui.graphics.vector.rememberVectorPainter 20 | import androidx.compose.ui.unit.dp 21 | import kotlinx.coroutines.launch 22 | import player.Player 23 | import player.SwitchMusicProxy 24 | import ui.component.AsyncImage 25 | import uk.co.caprica.vlcj.media.Meta 26 | import uk.co.caprica.vlcj.player.base.MediaPlayer 27 | import uk.co.caprica.vlcj.player.base.State 28 | import util.ImageUtil.loadImageBitmap 29 | import util.toTimeString 30 | import java.net.URI 31 | import kotlin.math.roundToInt 32 | 33 | @Composable 34 | fun MusicController() { 35 | val scope = rememberCoroutineScope() 36 | var sliderPosition by remember { mutableStateOf(0f) } 37 | var changedValue by remember { mutableStateOf(-1f) } 38 | val onPositionChanged = remember<(MediaPlayer?, Float) -> Unit> { 39 | { _, newPosition -> 40 | sliderPosition = if (changedValue < 0) newPosition else changedValue 41 | } 42 | } 43 | 44 | LaunchedEffect(onPositionChanged) { 45 | Player.addOnPositionChangedListener(onPositionChanged) 46 | } 47 | 48 | Row( 49 | modifier = Modifier 50 | .fillMaxWidth() 51 | .height(70.dp), 52 | verticalAlignment = Alignment.CenterVertically 53 | ) { 54 | MusicInfo() 55 | TimeLabel() 56 | Slider( 57 | modifier = Modifier.weight(1f), 58 | value = sliderPosition, 59 | onValueChange = { 60 | changedValue = it 61 | sliderPosition = it 62 | }, 63 | valueRange = 0f..1f, 64 | onValueChangeFinished = { 65 | scope.launch { 66 | Player.setPosition(changedValue) 67 | changedValue = -1f 68 | } 69 | } 70 | ) 71 | VolumnSlider() 72 | val switchMusicProxies = arrayOf( 73 | SwitchMusicProxy.Once, 74 | SwitchMusicProxy.Next, 75 | SwitchMusicProxy.RepeatOne, 76 | SwitchMusicProxy.ListLoop 77 | ) 78 | var switchMusicProxyIndex by remember { mutableStateOf(0) } 79 | Row( 80 | horizontalArrangement = Arrangement.Center 81 | ) { 82 | IconButton(onClick = { 83 | switchMusicProxyIndex = (switchMusicProxyIndex + 1) % switchMusicProxies.size 84 | Player.switchMusicProxy = switchMusicProxies[switchMusicProxyIndex] 85 | }) { 86 | Icon( 87 | imageVector = when (Player.switchMusicProxy) { 88 | SwitchMusicProxy.Once -> Icons.Outlined.LooksOne 89 | SwitchMusicProxy.RepeatOne -> Icons.Rounded.RepeatOne 90 | SwitchMusicProxy.Next -> Icons.Rounded.Redo 91 | SwitchMusicProxy.ListLoop -> Icons.Rounded.Repeat 92 | }, 93 | contentDescription = null 94 | ) 95 | } 96 | IconButton(onClick = { Player.previous() }) { 97 | Icon( 98 | imageVector = Icons.Rounded.SkipPrevious, contentDescription = null 99 | ) 100 | } 101 | IconButton( 102 | onClick = { 103 | scope.launch { 104 | if (Player.playState == State.PLAYING) Player.pause() else Player.play() 105 | } 106 | } 107 | ) { 108 | if (Player.playState == State.BUFFERING) { 109 | CircularProgressIndicator() 110 | } else { 111 | Icon( 112 | imageVector = if (Player.playState == State.PLAYING) Icons.Rounded.Pause 113 | else Icons.Rounded.PlayArrow, 114 | contentDescription = null 115 | ) 116 | } 117 | } 118 | IconButton(onClick = { Player.next() }) { 119 | Icon( 120 | imageVector = Icons.Rounded.SkipNext, contentDescription = null 121 | ) 122 | } 123 | } 124 | } 125 | 126 | DisposableEffect(onPositionChanged) { 127 | onDispose { 128 | Player.removeOnPositionChangedListener(onPositionChanged) 129 | } 130 | } 131 | } 132 | 133 | @Composable 134 | private fun MusicInfo() { 135 | if (Player.metaData != null) { 136 | Row( 137 | modifier = Modifier 138 | .padding(start = 16.dp) 139 | .fillMaxHeight(), 140 | verticalAlignment = Alignment.CenterVertically 141 | ) { 142 | Player.metaData?.get(Meta.ARTWORK_URL)?.let { artworkUrl -> 143 | if (!artworkUrl.startsWith("file")) return@let 144 | AsyncImage( 145 | modifier = Modifier 146 | .padding(vertical = 10.dp) 147 | .fillMaxHeight(), 148 | load = { 149 | var img: ImageBitmap? = null 150 | runCatching { 151 | img = loadImageBitmap(uri = URI(artworkUrl)) 152 | }.onFailure { 153 | img = null 154 | } 155 | img 156 | }, 157 | painterFor = { 158 | it?.toAwtImage()?.toPainter() ?: rememberVectorPainter(Icons.Rounded.Image) 159 | } 160 | ) 161 | } 162 | Column(modifier = Modifier.padding(start = 10.dp)) { 163 | println(Player.metaData?.get(Meta.ARTIST).orEmpty()) 164 | Text( 165 | modifier = Modifier.widthIn(max = 100.dp), 166 | text = Player.metaData?.get(Meta.TITLE).orEmpty(), 167 | style = MaterialTheme.typography.titleMedium, 168 | maxLines = 1 169 | ) 170 | val artist = Player.metaData?.get(Meta.ARTIST) 171 | if (!artist.isNullOrBlank()) { 172 | Text( 173 | modifier = Modifier.widthIn(max = 100.dp), 174 | text = artist, 175 | style = MaterialTheme.typography.labelLarge, 176 | maxLines = 1 177 | ) 178 | } 179 | } 180 | } 181 | } 182 | } 183 | 184 | @Composable 185 | private fun TimeLabel() { 186 | var currentTime by remember { mutableStateOf(0L) } 187 | val onTimeChanged = remember<(MediaPlayer?, Long) -> Unit> { 188 | { _, newTime -> 189 | currentTime = newTime 190 | } 191 | } 192 | 193 | LaunchedEffect(Unit) { 194 | Player.addOnTimeChangedListener(onTimeChanged) 195 | } 196 | Row( 197 | modifier = Modifier.padding(start = 16.dp, end = 12.dp), 198 | verticalAlignment = Alignment.CenterVertically 199 | ) { 200 | Text( 201 | text = currentTime.toTimeString(), 202 | style = MaterialTheme.typography.labelLarge 203 | ) 204 | Text( 205 | modifier = Modifier.padding(horizontal = 3.dp), 206 | text = "/", 207 | style = MaterialTheme.typography.labelLarge 208 | ) 209 | Text( 210 | text = Player.duration.toTimeString(), 211 | style = MaterialTheme.typography.labelLarge 212 | ) 213 | } 214 | 215 | DisposableEffect(onTimeChanged) { 216 | onDispose { 217 | Player.removeOnTimeChangedListener(onTimeChanged) 218 | } 219 | } 220 | } 221 | 222 | @Composable 223 | private fun VolumnSlider() { 224 | val scope = rememberCoroutineScope() 225 | var sliderPosition by remember { mutableStateOf(Player.volume) } 226 | var isVolumeOff by remember { mutableStateOf(Player.isMute) } 227 | Player.isMute = isVolumeOff 228 | Row( 229 | modifier = Modifier.width(150.dp), 230 | verticalAlignment = Alignment.CenterVertically 231 | ) { 232 | IconButton(onClick = { isVolumeOff = !isVolumeOff }) { 233 | Icon( 234 | imageVector = if (isVolumeOff) Icons.Rounded.VolumeOff 235 | else when (sliderPosition) { 236 | 0 -> Icons.Rounded.VolumeMute 237 | in 1..60 -> Icons.Rounded.VolumeDown 238 | in 61..100 -> Icons.Rounded.VolumeUp 239 | else -> Icons.Rounded.VolumeUp 240 | }, 241 | contentDescription = null 242 | ) 243 | } 244 | Slider( 245 | modifier = Modifier.weight(1f), 246 | value = sliderPosition.toFloat(), 247 | onValueChange = { 248 | sliderPosition = it.roundToInt() 249 | scope.launch { 250 | isVolumeOff = false 251 | Player.volume = sliderPosition 252 | } 253 | }, 254 | valueRange = 0f..200f 255 | ) 256 | } 257 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/lazyverticalgrid/AnimeItemSpace.kt: -------------------------------------------------------------------------------- 1 | package ui.component.lazyverticalgrid 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | import bean.MusicCover1Bean 8 | import com.skyd.db.History 9 | 10 | object AnimeItemSpace { 11 | val ITEM_SPACING = 12.dp 12 | val HORIZONTAL_PADDING = 16.dp 13 | 14 | fun Modifier.animeItemSpace(item: Any, spanSize: Int, spanIndex: Int) = 15 | this.padding(getItemSpace(item, spanSize, spanIndex)) 16 | 17 | fun getItemSpace(item: Any, spanSize: Int, spanIndex: Int): PaddingValues { 18 | var top = 0.dp 19 | var bottom = 0.dp 20 | var start = 0.dp 21 | var end = 0.dp 22 | if (needVerticalMargin(item.javaClass)) { 23 | top = 10.dp 24 | bottom = 2.dp 25 | } 26 | if (spanSize == MAX_SPAN_SIZE) { 27 | /** 28 | * 只有一列 29 | */ 30 | if (noHorizontalMargin(item.javaClass)) { 31 | return PaddingValues(top = top, bottom = bottom, start = start, end = end) 32 | } 33 | start = HORIZONTAL_PADDING 34 | end = HORIZONTAL_PADDING 35 | } else if (spanSize == MAX_SPAN_SIZE / 2) { 36 | /** 37 | * 只有两列,没有在中间的item 38 | * 2x = ITEM_SPACING 39 | */ 40 | val x = ITEM_SPACING / 2f 41 | if (spanIndex == 0) { 42 | start = HORIZONTAL_PADDING 43 | end = x 44 | } else { 45 | start = x 46 | end = HORIZONTAL_PADDING 47 | } 48 | } else if (spanSize == MAX_SPAN_SIZE / 3) { 49 | /** 50 | * 只有三列,一个在中间的item 51 | * HORIZONTAL_PADDING + x = 2y 52 | * x + y = ITEM_SPACING 53 | */ 54 | val y = (HORIZONTAL_PADDING + ITEM_SPACING) / 3f 55 | val x = ITEM_SPACING - y 56 | if (spanIndex == 0) { 57 | start = HORIZONTAL_PADDING 58 | end = x 59 | } else if (spanIndex + spanSize == MAX_SPAN_SIZE) { 60 | // 最右侧最后一个 61 | start = x 62 | end = HORIZONTAL_PADDING 63 | } else { 64 | start = y 65 | end = y 66 | } 67 | } else if (spanSize == MAX_SPAN_SIZE / 5) { 68 | /** 69 | * 只有五列 70 | * HORIZONTAL_PADDING + x = y + z 71 | * x + y = ITEM_SPACING 72 | * z + (HORIZONTAL_PADDING + x) / 2 = ITEM_SPACING 73 | */ 74 | val x = (ITEM_SPACING * 4 - HORIZONTAL_PADDING * 3) / 5f 75 | val y = ITEM_SPACING - x 76 | val z = HORIZONTAL_PADDING + x - y 77 | if (spanIndex == 0) { 78 | // 最左侧第一个 79 | start = HORIZONTAL_PADDING 80 | end = x 81 | } else if (spanIndex + spanSize == MAX_SPAN_SIZE) { 82 | // 最右侧最后一个 83 | start = x 84 | end = HORIZONTAL_PADDING 85 | } else if (spanIndex == spanSize) { 86 | // 第二个 87 | start = y 88 | end = z 89 | } else if (spanIndex == MAX_SPAN_SIZE - 2 * spanSize) { 90 | // 倒数第二个 91 | start = z 92 | end = y 93 | } else { 94 | // 最中间的 95 | start = (HORIZONTAL_PADDING + x) / 2f 96 | end = (HORIZONTAL_PADDING + x) / 2f 97 | } 98 | } else { 99 | /** 100 | * 多于三列(不包括五列),有在中间的item 101 | */ 102 | if ((MAX_SPAN_SIZE / spanSize) % 2 == 0) { 103 | /** 104 | * 偶数个item 105 | * HORIZONTAL_PADDING + x = y + ITEM_SPACING / 2 106 | * x + y = ITEM_SPACING 107 | */ 108 | val y = (HORIZONTAL_PADDING + ITEM_SPACING / 2f) / 2f 109 | val x = ITEM_SPACING - y 110 | if (spanIndex == 0) { 111 | // 最左侧第一个 112 | start = HORIZONTAL_PADDING 113 | end = x 114 | } else if (spanIndex + spanSize == MAX_SPAN_SIZE) { 115 | // 最右侧最后一个 116 | start = x 117 | end = HORIZONTAL_PADDING 118 | } else { 119 | // 中间的项目 120 | if (spanIndex < MAX_SPAN_SIZE / 2) { 121 | // 左侧部分 122 | start = y 123 | end = ITEM_SPACING / 2 124 | } else { 125 | // 右侧部分 126 | start = ITEM_SPACING / 2 127 | end = y 128 | } 129 | } 130 | } else { 131 | /** 132 | * 奇数个item,严格大于5的奇数(暂无需求,未实现) 133 | */ 134 | } 135 | } 136 | return PaddingValues(top = top, bottom = bottom, start = start, end = end) 137 | } 138 | 139 | private val noHorizontalMarginType: Set> = setOf( 140 | History::class.java, 141 | MusicCover1Bean::class.java, 142 | ) 143 | 144 | fun noHorizontalMargin(clz: Class<*>?): Boolean { 145 | clz ?: return true 146 | if (clz in noHorizontalMarginType) return true 147 | return false 148 | } 149 | 150 | private val needVerticalMarginType: Set> = setOf( 151 | ) 152 | 153 | fun needVerticalMargin(clz: Class<*>?): Boolean { 154 | clz ?: return false 155 | if (clz in needVerticalMarginType) return true 156 | return false 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/lazyverticalgrid/AnimeShowSpan.kt: -------------------------------------------------------------------------------- 1 | package ui.component.lazyverticalgrid 2 | 3 | import bean.MusicCover1Bean 4 | import com.skyd.db.History 5 | 6 | 7 | const val MAX_SPAN_SIZE = 60 8 | 9 | fun uPlayerSpan( 10 | data: Any 11 | ): Int = when (data) { 12 | is MusicCover1Bean, 13 | is History -> MAX_SPAN_SIZE 14 | else -> MAX_SPAN_SIZE 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/lazyverticalgrid/LazyGridAdapter.kt: -------------------------------------------------------------------------------- 1 | package ui.component.lazyverticalgrid 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Modifier 5 | import java.lang.reflect.ParameterizedType 6 | 7 | class LazyGridAdapter( 8 | private var proxyList: MutableList> = mutableListOf(), 9 | ) { 10 | @Suppress("UNCHECKED_CAST") 11 | @Composable 12 | fun Draw(modifier: Modifier, index: Int, data: Any) { 13 | val type: Int = getProxyIndex(data) 14 | if (type != -1) (proxyList[type] as Proxy).Draw(modifier, index, data) 15 | } 16 | 17 | // 获取策略在列表中的索引,可能返回-1 18 | private fun getProxyIndex(data: Any): Int = proxyList.indexOfFirst { 19 | // 如果Proxy中的第一个类型参数T和数据的类型相同,则返回对应策略的索引 20 | (it.javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0].let { argument -> 21 | if (argument.toString() == data.javaClass.toString()) 22 | true // 正常情况 23 | else if (((argument as? ParameterizedType)?.rawType as? Class<*>) 24 | ?.isAssignableFrom(data.javaClass) == true 25 | ) { 26 | true // data是T的子类的情况 27 | } else { 28 | // Proxy第一个泛型是类似List,又嵌套了个泛型 29 | if (argument is ParameterizedType) 30 | argument.rawType.toString() == data.javaClass.toString() 31 | else false 32 | } 33 | } 34 | } 35 | 36 | // 抽象策略类 37 | abstract class Proxy { 38 | @Composable 39 | abstract fun Draw(modifier: Modifier, index: Int, data: T) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/lazyverticalgrid/UPlayerLazyVerticalGrid.kt: -------------------------------------------------------------------------------- 1 | package ui.component.lazyverticalgrid 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.lazy.* 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import ui.component.lazyverticalgrid.AnimeItemSpace.animeItemSpace 10 | 11 | @OptIn(ExperimentalFoundationApi::class) 12 | @Composable 13 | fun UPlayerLazyVerticalGrid( 14 | modifier: Modifier = Modifier, 15 | contentPadding: PaddingValues = PaddingValues(), 16 | dataList: List, 17 | adapter: LazyGridAdapter, 18 | key: ((index: Int, item: Any) -> Any)? = null 19 | ) { 20 | val listState = rememberLazyGridState() 21 | val spanIndexArray: MutableList = remember { mutableListOf() } 22 | LazyVerticalGrid( 23 | modifier = modifier, 24 | cells = GridCells.Fixed(MAX_SPAN_SIZE), 25 | state = listState, 26 | contentPadding = contentPadding 27 | ) { 28 | itemsIndexed( 29 | items = dataList, 30 | key = key, 31 | span = { index, item -> 32 | val spanIndex = maxLineSpan - maxCurrentLineSpan 33 | if (spanIndexArray.size > index) spanIndexArray[index] = spanIndex 34 | else spanIndexArray.add(spanIndex) 35 | GridItemSpan(uPlayerSpan(item)) 36 | } 37 | ) { index, item -> 38 | adapter.Draw( 39 | modifier = Modifier.animeItemSpace( 40 | item = item, 41 | spanSize = uPlayerSpan(item), 42 | spanIndex = spanIndexArray[index] 43 | ), 44 | index = index, 45 | data = item 46 | ) 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/lazyverticalgrid/proxy/History1Proxy.kt: -------------------------------------------------------------------------------- 1 | package ui.component.lazyverticalgrid.proxy 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.DropdownMenu 9 | import androidx.compose.material.DropdownMenuItem 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.rounded.DeleteOutline 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.* 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.dp 19 | import com.skyd.db.History 20 | import db.appDatabase 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.launch 23 | import player.Player 24 | import ui.component.lazyverticalgrid.LazyGridAdapter 25 | import ui.history.updateHistoryListWithSort 26 | import util.time2Now 27 | 28 | class History1Proxy : LazyGridAdapter.Proxy() { 29 | @Composable 30 | override fun Draw(modifier: Modifier, index: Int, data: History) { 31 | History1Item(data = data) 32 | } 33 | } 34 | 35 | @OptIn(ExperimentalFoundationApi::class) 36 | @Composable 37 | fun History1Item(data: History) { 38 | val scope = rememberCoroutineScope() 39 | var expandedMenu by remember { mutableStateOf(false) } 40 | Row( 41 | modifier = Modifier 42 | .fillMaxWidth() 43 | .combinedClickable( 44 | onClick = {}, 45 | onDoubleClick = { 46 | scope.launch { 47 | Player.prepare(data.path) 48 | Player.play() 49 | } 50 | }, 51 | onLongClick = { expandedMenu = true } 52 | ) 53 | .padding(horizontal = 16.dp, vertical = 10.dp), 54 | verticalAlignment = Alignment.CenterVertically 55 | ) { 56 | HistoryItemMenu( 57 | history = data, 58 | expanded = expandedMenu, 59 | onDismissRequest = { expandedMenu = false } 60 | ) 61 | Text( 62 | modifier = Modifier.weight(1f), 63 | text = data.path, 64 | style = MaterialTheme.typography.titleMedium, 65 | color = MaterialTheme.colorScheme.primary 66 | ) 67 | Text( 68 | modifier = Modifier.padding(start = 10.dp), 69 | text = data.playTimestamp.time2Now(), 70 | style = MaterialTheme.typography.labelMedium, 71 | color = MaterialTheme.colorScheme.secondary 72 | ) 73 | } 74 | } 75 | 76 | @Composable 77 | private fun HistoryItemMenu(history: History, expanded: Boolean, onDismissRequest: () -> Unit) { 78 | val scope = rememberCoroutineScope() 79 | DropdownMenu( 80 | expanded = expanded, 81 | onDismissRequest = onDismissRequest 82 | ) { 83 | DropdownMenuItem( 84 | onClick = { 85 | onDismissRequest() 86 | scope.launch(Dispatchers.IO) { 87 | appDatabase.historyQueries.deleteHistory(path = history.path) 88 | updateHistoryListWithSort( 89 | appDatabase.historyQueries.getAllHistory().executeAsList() 90 | ) 91 | } 92 | }, 93 | ) { 94 | Row { 95 | Icon( 96 | imageVector = Icons.Rounded.DeleteOutline, 97 | contentDescription = null 98 | ) 99 | Text( 100 | modifier = Modifier.padding(start = 6.dp), 101 | text = "删除" 102 | ) 103 | } 104 | } 105 | } 106 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/lazyverticalgrid/proxy/MusicCover1Proxy.kt: -------------------------------------------------------------------------------- 1 | package ui.component.lazyverticalgrid.proxy 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.combinedClickable 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.DropdownMenu 9 | import androidx.compose.material.DropdownMenuItem 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.rounded.DeleteOutline 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.* 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.dp 19 | import bean.MusicCover1Bean 20 | import kotlinx.coroutines.Dispatchers 21 | import kotlinx.coroutines.launch 22 | import player.Player 23 | import ui.component.lazyverticalgrid.LazyGridAdapter 24 | 25 | class MusicCover1Proxy : LazyGridAdapter.Proxy() { 26 | @Composable 27 | override fun Draw(modifier: Modifier, index: Int, data: MusicCover1Bean) { 28 | MusicCover1Item(data = data) 29 | } 30 | } 31 | 32 | @OptIn(ExperimentalFoundationApi::class) 33 | @Composable 34 | fun MusicCover1Item(data: MusicCover1Bean) { 35 | val scope = rememberCoroutineScope() 36 | var expandedMenu by remember { mutableStateOf(false) } 37 | Row( 38 | modifier = Modifier 39 | .fillMaxWidth() 40 | .combinedClickable( 41 | onClick = {}, 42 | onDoubleClick = { 43 | scope.launch { 44 | Player.prepare(data.path) 45 | Player.play() 46 | } 47 | }, 48 | onLongClick = { expandedMenu = true } 49 | ) 50 | .padding(horizontal = 16.dp, vertical = 10.dp), 51 | verticalAlignment = Alignment.CenterVertically 52 | ) { 53 | PlayListItemMenu( 54 | bean = data, 55 | expanded = expandedMenu, 56 | onDismissRequest = { expandedMenu = false } 57 | ) 58 | Text( 59 | modifier = Modifier.weight(1f), 60 | text = data.path, 61 | style = MaterialTheme.typography.titleMedium, 62 | color = MaterialTheme.colorScheme.primary 63 | ) 64 | } 65 | } 66 | 67 | @Composable 68 | private fun PlayListItemMenu(bean: MusicCover1Bean, expanded: Boolean, onDismissRequest: () -> Unit) { 69 | val scope = rememberCoroutineScope() 70 | DropdownMenu( 71 | expanded = expanded, 72 | onDismissRequest = onDismissRequest 73 | ) { 74 | DropdownMenuItem( 75 | onClick = { 76 | onDismissRequest() 77 | scope.launch(Dispatchers.IO) { 78 | Player.musicList.remove(bean) 79 | } 80 | }, 81 | ) { 82 | Row { 83 | Icon( 84 | imageVector = Icons.Rounded.DeleteOutline, 85 | contentDescription = null 86 | ) 87 | Text( 88 | modifier = Modifier.padding(start = 6.dp), 89 | text = "删除" 90 | ) 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/component/sidebar/SideBar.kt: -------------------------------------------------------------------------------- 1 | package ui.component.sidebar 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.Card 7 | import androidx.compose.material.DropdownMenu 8 | import androidx.compose.material.DropdownMenuItem 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.outlined.InsertDriveFile 11 | import androidx.compose.material.icons.rounded.* 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.* 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.vector.ImageVector 19 | import androidx.compose.ui.unit.dp 20 | import kotlinx.coroutines.launch 21 | import player.Player 22 | import ui.BodyContentType 23 | import util.openFileDialog 24 | import util.OpenFileFilter 25 | 26 | @Composable 27 | fun SideBar(bodyContentType: MutableState) { 28 | var expandedMenu by remember { mutableStateOf(false) } 29 | Column(modifier = Modifier.width(200.dp).background(MaterialTheme.colorScheme.surface)) { 30 | Column( 31 | modifier = Modifier.weight(1f) 32 | ) { 33 | SideBarItem(icon = Icons.Rounded.Home, title = "首页") { 34 | bodyContentType.value = BodyContentType.Home 35 | } 36 | SideBarItem(icon = Icons.Rounded.Album, title = "歌单") { 37 | bodyContentType.value = BodyContentType.SongList 38 | } 39 | SideBarItem(icon = Icons.Rounded.History, title = "播放历史") { 40 | bodyContentType.value = BodyContentType.History 41 | } 42 | } 43 | SideBarItem(icon = Icons.Rounded.QueueMusic, title = "播放列表") { 44 | bodyContentType.value = BodyContentType.PlayList 45 | } 46 | SideBarItem(icon = Icons.Rounded.Menu, title = "菜单") { 47 | expandedMenu = !expandedMenu 48 | } 49 | MainMenu(expanded = expandedMenu, onDismissRequest = { expandedMenu = false }) 50 | } 51 | } 52 | 53 | @Composable 54 | private fun SideBarItem(icon: ImageVector, title: String, onClick: () -> Unit) { 55 | Card( 56 | modifier = Modifier 57 | .fillMaxWidth() 58 | .clickable(onClick = onClick) 59 | ) { 60 | Row( 61 | modifier = Modifier 62 | .wrapContentSize() 63 | .padding(horizontal = 26.dp, vertical = 15.dp), 64 | verticalAlignment = Alignment.CenterVertically 65 | ) { 66 | Icon(imageVector = icon, contentDescription = null) 67 | Text( 68 | modifier = Modifier 69 | .weight(1f) 70 | .padding(start = 12.dp), 71 | text = title, 72 | style = MaterialTheme.typography.labelLarge 73 | ) 74 | } 75 | } 76 | } 77 | 78 | @Composable 79 | private fun MainMenu(expanded: Boolean, onDismissRequest: () -> Unit) { 80 | val scope = rememberCoroutineScope() 81 | DropdownMenu( 82 | expanded = expanded, 83 | onDismissRequest = onDismissRequest 84 | ) { 85 | DropdownMenuItem( 86 | onClick = { 87 | onDismissRequest() 88 | scope.launch { 89 | openFileDialog( 90 | filters = listOf( 91 | OpenFileFilter("mp3;wav;m4a;flac", "音频文件"), 92 | OpenFileFilter("mp3", "MP3文件"), 93 | OpenFileFilter("wav", "WAV文件"), 94 | OpenFileFilter("m4a", "M4A文件"), 95 | OpenFileFilter("flac", "FLAC文件"), 96 | ) 97 | ).let { 98 | if (!it.isNullOrBlank()) { 99 | Player.prepare(it) 100 | Player.play() 101 | } 102 | } 103 | } 104 | }, 105 | ) { 106 | Row { 107 | Icon( 108 | imageVector = Icons.Outlined.InsertDriveFile, 109 | contentDescription = null 110 | ) 111 | Text( 112 | modifier = Modifier.padding(start = 6.dp), 113 | text = "打开" 114 | ) 115 | } 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/history/HistoryScreen.kt: -------------------------------------------------------------------------------- 1 | package ui.history 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.material.Text 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.outlined.DeleteOutline 7 | import androidx.compose.material.icons.rounded.History 8 | import androidx.compose.material.icons.rounded.Warning 9 | import androidx.compose.material3.ExperimentalMaterial3Api 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Modifier 14 | import com.skyd.db.History 15 | import db.appDatabase 16 | import kotlinx.coroutines.Dispatchers 17 | import kotlinx.coroutines.launch 18 | import ui.component.MessageDialog 19 | import ui.component.TopBarIcon 20 | import ui.component.UPlayerTopBar 21 | import ui.component.lazyverticalgrid.LazyGridAdapter 22 | import ui.component.lazyverticalgrid.UPlayerLazyVerticalGrid 23 | import ui.component.lazyverticalgrid.proxy.History1Proxy 24 | 25 | var historyList: List by mutableStateOf(listOf()) 26 | private set 27 | 28 | fun updateHistoryListWithSort(list: List) { 29 | historyList = list.sortedBy { -it.playTimestamp } 30 | } 31 | 32 | @OptIn(ExperimentalMaterial3Api::class) 33 | @Composable 34 | fun HistoryScreen() { 35 | val scope = rememberCoroutineScope() 36 | var showWarningDeleteDialog by remember { mutableStateOf(false) } 37 | LaunchedEffect(Unit) { 38 | updateHistoryListWithSort(appDatabase.historyQueries.getAllHistory().executeAsList()) 39 | } 40 | Scaffold( 41 | topBar = { 42 | UPlayerTopBar( 43 | title = { 44 | Text( 45 | text = "播放历史", 46 | style = MaterialTheme.typography.titleLarge 47 | ) 48 | }, 49 | navigationIcon = { 50 | TopBarIcon( 51 | imageVector = Icons.Rounded.History, 52 | contentDescription = null 53 | ) 54 | }, 55 | actions = { 56 | TopBarIcon( 57 | imageVector = Icons.Outlined.DeleteOutline, 58 | contentDescription = null, 59 | onClick = { showWarningDeleteDialog = true } 60 | ) 61 | } 62 | ) 63 | } 64 | ) { 65 | val adapter: LazyGridAdapter = remember { LazyGridAdapter(mutableListOf(History1Proxy())) } 66 | UPlayerLazyVerticalGrid( 67 | modifier = Modifier.fillMaxWidth(), 68 | dataList = historyList, 69 | adapter = adapter 70 | ) 71 | } 72 | if (showWarningDeleteDialog) { 73 | MessageDialog( 74 | icon = Icons.Rounded.Warning, 75 | message = "确定要删除所有历史记录吗?", 76 | onNegative = { 77 | showWarningDeleteDialog = false 78 | }, 79 | onPositive = { 80 | showWarningDeleteDialog = false 81 | scope.launch(Dispatchers.IO) { 82 | appDatabase.historyQueries.deleteAllHistories() 83 | updateHistoryListWithSort(emptyList()) 84 | } 85 | }, 86 | onDismissRequest = {} 87 | ) 88 | } 89 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/main/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package ui.main 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.mutableStateOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import ui.BodyContentType 10 | import ui.component.controller.MusicController 11 | import ui.component.sidebar.SideBar 12 | import ui.history.HistoryScreen 13 | import ui.playlist.PlayListScreen 14 | 15 | @Composable 16 | fun MainScreen() { 17 | val bodyContentType = remember { mutableStateOf(BodyContentType.Home) } 18 | Column( 19 | modifier = Modifier.fillMaxSize() 20 | ) { 21 | Row( 22 | modifier = Modifier 23 | .fillMaxWidth() 24 | .weight(1f) 25 | ) { 26 | SideBar(bodyContentType = bodyContentType) 27 | BodyContent(bodyContentType = bodyContentType.value, modifier = Modifier.weight(1f)) 28 | 29 | } 30 | MusicController() 31 | } 32 | } 33 | 34 | @Composable 35 | fun BodyContent(bodyContentType: BodyContentType, modifier: Modifier = Modifier) { 36 | Crossfade( 37 | modifier = modifier.fillMaxHeight(), 38 | targetState = bodyContentType 39 | ) { 40 | when (it) { 41 | BodyContentType.History -> HistoryScreen() 42 | BodyContentType.PlayList -> PlayListScreen() 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/kotlin/ui/playlist/PlayListScreen.kt: -------------------------------------------------------------------------------- 1 | package ui.playlist 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.material.Text 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.rounded.QueueMusic 7 | import androidx.compose.material3.ExperimentalMaterial3Api 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Scaffold 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import player.Player 14 | import ui.component.TopBarIcon 15 | import ui.component.UPlayerTopBar 16 | import ui.component.lazyverticalgrid.LazyGridAdapter 17 | import ui.component.lazyverticalgrid.UPlayerLazyVerticalGrid 18 | import ui.component.lazyverticalgrid.proxy.MusicCover1Proxy 19 | 20 | @OptIn(ExperimentalMaterial3Api::class) 21 | @Composable 22 | fun PlayListScreen() { 23 | Scaffold( 24 | topBar = { 25 | UPlayerTopBar( 26 | title = { 27 | Text( 28 | text = "播放列表", 29 | style = MaterialTheme.typography.titleLarge 30 | ) 31 | }, 32 | navigationIcon = { 33 | TopBarIcon( 34 | imageVector = Icons.Rounded.QueueMusic, 35 | contentDescription = null 36 | ) 37 | } 38 | ) 39 | } 40 | ) { 41 | val adapter: LazyGridAdapter = remember { LazyGridAdapter(mutableListOf(MusicCover1Proxy())) } 42 | UPlayerLazyVerticalGrid( 43 | modifier = Modifier.fillMaxWidth(), 44 | dataList = Player.musicList, 45 | adapter = adapter 46 | ) 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/util/Dialog.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import config.Config.currentPath 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.launch 7 | import java.awt.Frame 8 | import java.io.File 9 | import java.util.* 10 | import javax.swing.JFileChooser 11 | import javax.swing.filechooser.FileFilter 12 | 13 | @Suppress("UsePropertyAccessSyntax") 14 | fun openFileDialog(parent: Frame? = null, filters: List? = null): String? { 15 | val scope = CoroutineScope(Dispatchers.IO) 16 | val jFileChooser = JFileChooser(currentPath) 17 | if (!filters.isNullOrEmpty()) { 18 | scope.launch(Dispatchers.IO) { 19 | jFileChooser.removeChoosableFileFilter(jFileChooser.acceptAllFileFilter) 20 | filters.forEach { jFileChooser.addChoosableFileFilter(it) } 21 | } 22 | } 23 | val returnVal: Int = jFileChooser.showOpenDialog(parent) 24 | if (returnVal == JFileChooser.APPROVE_OPTION) { 25 | return jFileChooser.getSelectedFile().path 26 | } 27 | return null 28 | } 29 | 30 | class OpenFileFilter : FileFilter { 31 | var typeDescription = "" 32 | var fileExt: List 33 | 34 | constructor(extension: String) { 35 | fileExt = extension.split(";") 36 | } 37 | 38 | constructor(extension: String, typeDescription: String) { 39 | fileExt = extension.split(";") 40 | this.typeDescription = typeDescription 41 | } 42 | 43 | override fun accept(f: File?): Boolean { 44 | f ?: return false 45 | return if (f.isDirectory) true else { 46 | f.name.lowercase(Locale.getDefault()).substringAfterLast(".") in fileExt 47 | } 48 | } 49 | 50 | override fun getDescription(): String = typeDescription 51 | } -------------------------------------------------------------------------------- /src/main/kotlin/util/ImageUtil.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import androidx.compose.ui.graphics.ImageBitmap 4 | import androidx.compose.ui.graphics.painter.Painter 5 | import androidx.compose.ui.graphics.vector.ImageVector 6 | import androidx.compose.ui.res.loadImageBitmap 7 | import androidx.compose.ui.res.loadSvgPainter 8 | import androidx.compose.ui.res.loadXmlImageVector 9 | import androidx.compose.ui.unit.Density 10 | import org.xml.sax.InputSource 11 | import java.io.File 12 | import java.net.URI 13 | import java.net.URL 14 | 15 | object ImageUtil { 16 | fun loadImageBitmap(bytes: ByteArray): ImageBitmap = 17 | bytes.inputStream().buffered().use(::loadImageBitmap) 18 | 19 | fun loadImageBitmap(uri: URI): ImageBitmap = 20 | File(uri).inputStream().buffered().use(::loadImageBitmap) 21 | 22 | /* Loading from file with java.io API */ 23 | fun loadImageBitmap(file: File): ImageBitmap = 24 | file.inputStream().buffered().use(::loadImageBitmap) 25 | 26 | fun loadSvgPainter(file: File, density: Density): Painter = 27 | file.inputStream().buffered().use { loadSvgPainter(it, density) } 28 | 29 | fun loadXmlImageVector(file: File, density: Density): ImageVector = 30 | file.inputStream().buffered().use { loadXmlImageVector(InputSource(it), density) } 31 | 32 | /* Loading from network with java.net API */ 33 | fun loadImageBitmap(url: String): ImageBitmap = 34 | URL(url).openStream().buffered().use(::loadImageBitmap) 35 | 36 | fun loadSvgPainter(url: String, density: Density): Painter = 37 | URL(url).openStream().buffered().use { loadSvgPainter(it, density) } 38 | 39 | fun loadXmlImageVector(url: String, density: Density): ImageVector = 40 | URL(url).openStream().buffered().use { loadXmlImageVector(InputSource(it), density) } 41 | } -------------------------------------------------------------------------------- /src/main/kotlin/util/PointerEventUtil.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import androidx.compose.ui.input.pointer.AwaitPointerEventScope 4 | import androidx.compose.ui.input.pointer.PointerEvent 5 | import androidx.compose.ui.input.pointer.changedToDown 6 | 7 | suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { 8 | var event: PointerEvent 9 | do { 10 | event = awaitPointerEvent() 11 | } while ( 12 | !event.changes.all { it.changedToDown() } 13 | ) 14 | return event 15 | } 16 | -------------------------------------------------------------------------------- /src/main/kotlin/util/TimeUtil.kt: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.* 5 | 6 | fun Long.toTimeString(): String { 7 | val hh = String.format("%02d", this / 1000 / 60 / 60) 8 | val mm = String.format("%02d", (this / 1000 / 60) % 60) 9 | val ss = String.format("%02d", (this / 1000) % 60) 10 | return if (hh != "00") "$hh:$mm:$ss" else "$mm:$ss" 11 | } 12 | 13 | fun Long.toDateString( 14 | pattern: String = "yyyy-MM-dd HH:mm:ss", 15 | locale: Locale = Locale.getDefault() 16 | ): String = Date(this).toDateString(pattern, locale) 17 | 18 | fun Date.toDateString( 19 | pattern: String = "yyyy-MM-dd HH:mm:ss", 20 | locale: Locale = Locale.getDefault() 21 | ): String { 22 | val format = SimpleDateFormat(pattern, locale) 23 | return format.format(this) 24 | } 25 | 26 | /** 27 | * 计算距今时间 28 | */ 29 | fun Long.time2Now(): String { 30 | val nowTimeStamp = System.currentTimeMillis() 31 | var result = "非法输入" 32 | val dateDiff = nowTimeStamp - this 33 | if (dateDiff >= 0) { 34 | val bef = Calendar.getInstance().apply { time = Date(this@time2Now) } 35 | val aft = Calendar.getInstance().apply { time = Date(nowTimeStamp) } 36 | val second = dateDiff / 1000.0 37 | val minute = second / 60.0 38 | val hour = minute / 60.0 39 | val day = hour / 24.0 40 | val month = 41 | aft[Calendar.MONTH] - bef[Calendar.MONTH] + (aft[Calendar.YEAR] - bef[Calendar.YEAR]) * 12 42 | val year = month / 12.0 43 | result = when { 44 | year.toInt() > 0 -> "${year.toInt()}年前" 45 | month > 0 -> "${month}个月前" 46 | day.toInt() > 0 -> "${day.toInt()}天前" 47 | hour.toInt() > 0 -> "${hour.toInt()}小时前" 48 | minute.toInt() > 0 -> "${minute.toInt()}分钟前" 49 | else -> "刚刚" 50 | } 51 | } 52 | return result 53 | } -------------------------------------------------------------------------------- /src/main/resources/image/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 14 | 15 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/sqldelight/com/skyd/db/History.sq: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS History ( 2 | path TEXT NOT NULL PRIMARY KEY, 3 | playTimestamp INTEGER NOT NULL 4 | ); 5 | 6 | getAllHistory: 7 | SELECT * FROM History; 8 | 9 | insertOrUpdateHistory: 10 | INSERT OR REPLACE INTO History(path, playTimestamp) VALUES(?, ?); 11 | 12 | deleteHistory: 13 | DELETE FROM History WHERE path IS :path; 14 | 15 | deleteAllHistories: 16 | DELETE FROM History; --------------------------------------------------------------------------------