├── .github └── workflows │ └── main.yml ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images ├── 1.png ├── 2.png ├── settings1.png ├── settings2.png ├── settings3.png └── web.png ├── settings.gradle.kts └── src └── main ├── java └── com │ └── zmxl │ └── plugin │ ├── SpwControlPlugin.kt │ ├── control │ └── SmtcController.kt │ ├── lyrics │ └── DesktopLyrics.kt │ ├── playback │ ├── PlaybackExtension.kt │ └── PlaybackStateHolder.kt │ └── server │ └── HttpServer.kt └── resources ├── icon.png ├── preference_config.json └── web └── index.html /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: set up JDK 21 16 | uses: actions/setup-java@v3 17 | with: 18 | java-version: '21' 19 | distribution: 'temurin' 20 | cache: gradle 21 | 22 | - name: Grant execute permission for gradlew 23 | run: chmod +x gradlew 24 | 25 | - name: Build with Gradle 26 | run: ./gradlew plugin 27 | 28 | - name: Upload 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: SaltLyricPlugin 32 | path: build/libs/* 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2025] [zmxl] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaltLyricPlugin 2 | 一个适用于 Salt Player For Windows 的桌面歌词插件,提供桌面歌词显示及 HTTP API 控制功能。 3 | ## 功能特点 4 | * 桌面歌词显示:支持中文、日文、英文等多语言字体自动适配,支持逐字歌词 5 | * 歌词翻译支持:自动识别并显示带翻译的歌词(原文 + 翻译合并显示) 6 | * 在线歌词获取:对于未内嵌歌词的歌曲,通过网易云、QQ、酷狗等在线音乐平台获取歌词 7 | * HTTP API 接口:提供音乐控制及歌词获取接口,方便第三方集成[PS:真的会有人集成吗……] 8 | ## 安装方法 9 | 1. 下载插件 10 | 2. 进入SPW→设置→创意工坊→模组管理→导入→SaltLyricPlugin→启用,[初次使用必须启用后重启,否则会导致功能异常] 11 | ## 编译 12 | ``` 13 | gradlew plugin 14 | ``` 15 | ## 其他插件: 16 | [任务栏歌词插件](https://github.com/zmxlsss666/TaskbarLyricsPlugin) 17 | 18 | [播放进度保存插件](https://github.com/zmxlsss666/SaltTimePlugin) 19 | ## 软件截图 20 | ### 桌面歌词 21 | #### 未锁定: 22 | ![未锁定](https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/refs/heads/main/images/1.png) 23 | #### 锁定 24 | 锁定后可在托盘菜单中解锁 25 | ![锁定](https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/refs/heads/main/images/2.png) 26 | #### 设置 27 | ![设置1](https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/refs/heads/main/images/settings1.png) 28 | ![设置2](https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/refs/heads/main/images/settings2.png) 29 | ![设置3](https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/refs/heads/main/images/settings3.png) 30 | ### Web 控制界面 31 | 浏览器访问 `http://localhost:35373` 即可进入控制界面,(目前歌曲封面未使用歌曲内嵌封面,有概率无法获取,未适配逐字歌词) 32 | ![Web控制界面](https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/refs/heads/main/images/web.png) 33 | ## API 文档 34 | 插件提供以下 HTTP API 接口,方便第三方应用集成: 35 | ### 1. 获取当前播放信息 36 | * **端点**:`/api/now-playing` 37 | * **方法**:`GET` 38 | * **响应示例**: 39 | ``` 40 | { 41 | "status": "success", 42 | "title": "歌曲标题", 43 | "artist": "艺术家", 44 | "album": "专辑", 45 | "isPlaying": true, 46 | "position": 123456, 47 | "volume": 80, 48 | "timestamp": 1620000000000 49 | } 50 | ``` 51 | ### 2. 播放 / 暂停切换 52 | * **端点**:`/api/play-pause` 53 | * **方法**:`GET` 54 | * **响应示例**: 55 | ``` 56 | { 57 | "status": "success", 58 | "action": "play\_pause\_toggled", 59 | "isPlaying": true, 60 | "message": "已开始播放" 61 | } 62 | ``` 63 | ### 3. 下一曲 64 | * **端点**:`/api/next-track` 65 | * **方法**:`GET` 66 | * **响应示例**: 67 | ``` 68 | { 69 | "status": "success", 70 | "action": "next\_track", 71 | "newTrack": "下一首歌曲", 72 | "message": "已切换到下一曲" 73 | } 74 | ``` 75 | ### 4. 上一曲 76 | * **端点**:`/api/previous-track` 77 | * **方法**:`GET` 78 | * **响应示例**: 79 | ``` 80 | { 81 | "status": "success", 82 | "action": "previous\_track", 83 | "newTrack": "上一首歌曲", 84 | "message": "已切换到上一曲" 85 | } 86 | ``` 87 | ### 5. 音量调节 88 | * **音量增加**:`/api/volume/up`(GET) 89 | * **音量减少**:`/api/volume/down`(GET) 90 | * **静音切换**:`/api/mute`(GET) 91 | ### 6. 获取歌词(使用歌曲文件内嵌歌词) 92 | * **端点**:`/api/lyric` 93 | * **方法**:`GET` 94 | * **响应示例**: 95 | ``` 96 | { 97 | "status": "success", 98 | "lyric": "\[00:00.00]歌词内容..." 99 | } 100 | ``` 101 | * 其他来源歌词: 102 | * 网易云歌词:`/api/lyric163` 103 | * QQ 音乐歌词:`/api/lyricqq` 104 | * 酷狗音乐歌词:`/api/lyrickugou` 105 | * SPW内置歌词(仅当前播放行):`/api/lyricspw` 106 | ### 7. 获取当前播放位置 107 | * **端点**:`/api/current-position` 108 | * **方法**:`GET` 109 | * **响应示例**: 110 | ``` 111 | { 112 | "status": "success", 113 | "position": 123456 114 | } 115 | ``` 116 | 117 | # 致谢 118 | 本项目的开发离不开以下开源项目的支持与贡献,在此表示诚挚的感谢: 119 | * **Kotlin** 120 | [https://kotlinlang.org/](https://kotlinlang.org/) 121 | 122 | 项目主要开发语言,提供了简洁、安全的编程体验,其 JVM 生态为项目开发提供了坚实基础。 123 | 124 | 许可证:[Apache License 2.0](https://github.com/JetBrains/kotlin/blob/master/LICENSE.txt) 125 | * **spw-workshop-api** 126 | [https://github.com/Moriafly/spw-workshop-api](https://github.com/Moriafly/spw-workshop-api) 127 | 128 | 提供 Salt Player For Windows 插件开发的核心接口,是本插件与播放器交互的基础。 129 | 130 | 许可证:[Apache License 2.0](https://github.com/Moriafly/spw-workshop-api/blob/main/LICENSE) 131 | * **Spark Java** 132 | [https://github.com/perwendel/spark](https://github.com/perwendel/spark) 133 | 134 | 轻量级 Java Web 框架,用于实现本项目的 HTTP 服务及 API 接口,简化了 Web 服务开发流程。 135 | 136 | 许可证:[Apache License 2.0](https://github.com/perwendel/spark/blob/master/LICENSE) 137 | * **JNA (Java Native Access)** 138 | [https://github.com/java-native-access/jna](https://github.com/java-native-access/jna) 139 | 140 | 提供 Java 与原生代码的交互能力,为本项目实现 Windows 系统媒体控制(如媒体键响应)提供了关键支持。 141 | 142 | 许可证:[Apache License 2.0](https://github.com/java-native-access/jna/blob/master/LICENSE) 143 | * **Gson** 144 | [https://github.com/google/gson](https://github.com/google/gson) 145 | 146 | Google 开发的 JSON 处理库,用于 API 接口的请求与响应数据序列化 / 反序列化。 147 | 148 | 许可证:[Apache License 2.0](https://github.com/google/gson/blob/master/LICENSE) 149 | * **SLF4J** 150 | [https://www.slf4j.org/](https://www.slf4j.org/) 151 | 152 | 简单日志门面,为项目提供统一的日志接口,简化了日志系统的集成与管理。 153 | 154 | 许可证:[MIT License](https://www.slf4j.org/license.html) 155 | * **Eclipse Jetty** 156 | [https://www.eclipse.org/jetty/](https://www.eclipse.org/jetty/) 157 | 158 | 轻量级 Java Web 服务器,用于支撑本项目的 HTTP 服务运行。 159 | 160 | 许可证:[Apache License 2.0](https://www.eclipse.org/jetty/licenses.html) 161 | * **Jaudiotagger** 162 | [https://bitbucket.org/ijabz/jaudiotagger](https://bitbucket.org/ijabz/jaudiotagger) 163 | 164 | 用于实现歌词内容的解析与处理。 165 | 166 | 许可证:[GNU Lesser General Public License v2.1](https://bitbucket.org/ijabz/jaudiotagger/src/master/license.txt) 167 | * **Font Awesome** 168 | [https://fontawesome.com/](https://fontawesome.com/) 169 | 170 | 提供丰富的图标资源,美化了 Web 控制界面的视觉呈现。 171 | 172 | 许可证:[CC BY 4.0 License](https://fontawesome.com/license) 173 | 174 | 如果没有这些优秀的开源项目,本插件无法成功开发。再次感谢以上所有开源项目的开发者及贡献者们的辛勤付出! 175 | ## 许可证 176 | 本项目采用 [Apache License 2.0](LICENSE) 开源协议。 177 | 详细条款请参见根目录下的 `LICENSE` 文件。使用本项目前,请确保遵守许可条款。 178 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java-library") 3 | kotlin("jvm") version "1.9.22" 4 | kotlin("kapt") version "1.9.22" 5 | } 6 | 7 | java { 8 | sourceCompatibility = JavaVersion.VERSION_21 9 | targetCompatibility = JavaVersion.VERSION_21 10 | } 11 | 12 | kotlin { 13 | jvmToolchain(21) 14 | } 15 | 16 | dependencies { 17 | compileOnly("com.github.Moriafly:spw-workshop-api:0.1.0-dev10") 18 | kapt("com.github.Moriafly:spw-workshop-api:0.1.0-dev10") 19 | implementation("org.eclipse.jetty:jetty-server:11.0.15") 20 | implementation("org.eclipse.jetty:jetty-servlet:11.0.15") 21 | compileOnly("net.java.dev.jna:jna:5.10.0") 22 | compileOnly("net.java.dev.jna:jna-platform:5.10.0") 23 | implementation("org.json:json:20210307") 24 | implementation(kotlin("stdlib-jdk8")) 25 | implementation("com.google.code.gson:gson:2.10.1") 26 | compileOnly ("net.jthink:jaudiotagger:3.0.1") 27 | } 28 | val pluginClass = "com.zmxl.plugin.SpwControlPlugin" 29 | val pluginId = "SaltLyricPlugin" 30 | val pluginVersion = "2.1.3" 31 | val pluginProvider = "zmxl" 32 | val PluginHasConfig = "true" 33 | val PluginOpenSourceUrl = "https://github.com/zmxlsss666/SaltLyricPlugin" 34 | val PluginDescription = "一个适用于 Salt Player For Windows 的桌面歌词插件,觉得好用请点个Star" 35 | 36 | tasks.named("jar") { 37 | manifest { 38 | attributes["Plugin-Class"] = pluginClass 39 | attributes["Plugin-Id"] = pluginId 40 | attributes["Plugin-Version"] = pluginVersion 41 | attributes["Plugin-Provider"] = pluginProvider 42 | attributes["Plugin-Has-Config"] = PluginHasConfig 43 | attributes["Plugin-Open-Source-Url"] = PluginOpenSourceUrl 44 | attributes["Plugin-Description"] = PluginDescription 45 | } 46 | } 47 | 48 | tasks.register("plugin") { 49 | archiveBaseName.set("plugin-$pluginId-$pluginVersion") 50 | into("classes") { 51 | with(tasks.named("jar").get()) 52 | } 53 | dependsOn(configurations.runtimeClasspath) 54 | into("lib") { 55 | from(configurations.runtimeClasspath.get().filter { it.name.endsWith("jar") }) 56 | } 57 | archiveExtension.set("zip") 58 | } 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.0.21" 3 | spw-workshop-api = "0.1.0-dev08" 4 | spark = "2.9.4" 5 | jna = "5.14.0" 6 | gson = "2.10.1" 7 | slf4j = "2.0.9" 8 | 9 | [libraries] 10 | spw-workshop-api = { group = "com.github.Moriafly", name = "spw-workshop-api", version.ref = "spw-workshop-api" } 11 | spark-core = { group = "com.sparkjava", name = "spark-core", version.ref = "spark" } 12 | jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } 13 | jna-platform = { group = "net.java.dev.jna", name = "jna-platform", version.ref = "jna" } 14 | gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } 15 | slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } 16 | slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } 17 | 18 | [plugins] 19 | jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 20 | 21 | jetbrainsKotlinKapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } 22 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://github.com/gradle/gradle-distributions/releases/download/v8.5.0/gradle-8.5-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or 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 UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/images/1.png -------------------------------------------------------------------------------- /images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/images/2.png -------------------------------------------------------------------------------- /images/settings1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/images/settings1.png -------------------------------------------------------------------------------- /images/settings2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/images/settings2.png -------------------------------------------------------------------------------- /images/settings3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/images/settings3.png -------------------------------------------------------------------------------- /images/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/images/web.png -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | gradlePluginPortal() 5 | maven { url = uri("https://jitpack.io") } 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) // 强制使用此处的仓库配置 11 | repositories { 12 | mavenCentral() 13 | maven { url = uri("https://jitpack.io") } 14 | } 15 | } 16 | 17 | rootProject.name = "spw-control-plugin" -------------------------------------------------------------------------------- /src/main/java/com/zmxl/plugin/SpwControlPlugin.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 zmxl 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | @file:OptIn(UnstableSpwWorkshopApi::class) 17 | package com.zmxl.plugin 18 | import org.pf4j.Plugin 19 | import org.pf4j.PluginWrapper 20 | import com.zmxl.plugin.server.HttpServer 21 | import com.zmxl.plugin.control.SmtcController 22 | import com.zmxl.plugin.lyrics.DesktopLyrics 23 | import com.xuncorp.spw.workshop.api.WorkshopApi 24 | import com.xuncorp.spw.workshop.api.UnstableSpwWorkshopApi 25 | class SpwControlPlugin(wrapper: PluginWrapper) : Plugin(wrapper) { 26 | private lateinit var httpServer: HttpServer 27 | private var lyricsApp: DesktopLyrics? = null 28 | override fun start() { 29 | super.start() 30 | println("SPW Control Plugin 开始启动...") 31 | // 初始化ConfigManager 32 | try { 33 | val configManager = WorkshopApi.instance.manager.createConfigManager(wrapper.pluginId) 34 | println("ConfigManager 初始化成功") 35 | // 启动桌面歌词应用并传递ConfigManager 36 | lyricsApp = DesktopLyrics 37 | lyricsApp?.setConfigManager(configManager) 38 | println("DesktopLyrics ConfigManager 设置成功") 39 | } catch (e: Exception) { 40 | println("ConfigManager 初始化失败: ${e.message}") 41 | // 如果无法创建ConfigManager,DesktopLyrics将使用旧式配置文件 42 | lyricsApp = DesktopLyrics 43 | } 44 | // 启动HTTP服务器 45 | httpServer = HttpServer(35373) 46 | httpServer.start() 47 | println("HTTP服务器启动成功,端口: 35373") 48 | // 初始化SMTC控制器 49 | SmtcController.init() 50 | println("SMTC控制器初始化成功") 51 | // 启动桌面歌词 52 | lyricsApp?.start() 53 | println("桌面歌词启动成功") 54 | println("SPW Control Plugin 启动完成") 55 | } 56 | override fun stop() { 57 | super.stop() 58 | println("SPW Control Plugin 开始停止...") 59 | httpServer.stop() 60 | println("HTTP服务器已停止") 61 | SmtcController.shutdown() 62 | println("SMTC控制器已关闭") 63 | // 停止歌词应用 64 | lyricsApp?.stop() 65 | lyricsApp = null 66 | println("桌面歌词已停止") 67 | println("SPW Control Plugin 已完全停止") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/com/zmxl/plugin/control/SmtcController.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 zmxl 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.zmxl.plugin.control 17 | 18 | import com.sun.jna.* 19 | import com.sun.jna.platform.win32.* 20 | import com.sun.jna.win32.W32APIOptions 21 | import com.xuncorp.spw.workshop.api.WorkshopApi 22 | import com.zmxl.plugin.playback.PlaybackStateHolder 23 | import com.zmxl.plugin.playback.SpwPlaybackExtension 24 | import java.util.concurrent.Executors 25 | import java.util.concurrent.ScheduledExecutorService 26 | import java.util.concurrent.TimeUnit 27 | 28 | object SmtcController { 29 | private val playbackExtension = SpwPlaybackExtension() 30 | private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() 31 | private var hwnd: WinDef.HWND? = null 32 | private var windowClass: String = "SPW_MEDIA_CONTROL_CLASS" 33 | private var messageLoopThread: Thread? = null 34 | private var isRunning = false 35 | 36 | // Windows API常量定义 37 | private val WM_APPCOMMAND = 0x0319 38 | private val APPCOMMAND_MEDIA_PLAY_PAUSE = 14 39 | private val APPCOMMAND_MEDIA_NEXTTRACK = 11 40 | private val APPCOMMAND_MEDIA_PREVIOUSTRACK = 12 41 | private val APPCOMMAND_VOLUME_UP = 10 42 | private val APPCOMMAND_VOLUME_DOWN = 9 43 | private val APPCOMMAND_VOLUME_MUTE = 8 44 | private val CW_USEDEFAULT = 0x80000000.toInt() 45 | 46 | // 添加一个标志来检测Workshop API是否可用 47 | private var workshopApiAvailable = false 48 | 49 | fun init() { 50 | if (isRunning) return 51 | 52 | isRunning = true 53 | 54 | // 检查Workshop API是否可用 55 | workshopApiAvailable = checkWorkshopApiAvailable() 56 | 57 | registerWindowClass() 58 | createWindow() 59 | startMessageLoop() 60 | registerMediaKeys() 61 | 62 | println("SMTC控制器初始化完成,Workshop API ${if (workshopApiAvailable) "可用" else "不可用"}") 63 | } 64 | 65 | private fun checkWorkshopApiAvailable(): Boolean { 66 | return try { 67 | // 尝试访问Workshop API的方法来检查是否可用 68 | WorkshopApi.instance.playback 69 | true 70 | } catch (e: Exception) { 71 | false 72 | } 73 | } 74 | 75 | private fun registerWindowClass() { 76 | val hInstance = Kernel32.INSTANCE.GetModuleHandle(null) 77 | 78 | val wc = WinUser.WNDCLASSEX() 79 | wc.cbSize = wc.size() 80 | wc.lpfnWndProc = object : WinUser.WindowProc { 81 | override fun callback(hwnd: WinDef.HWND?, uMsg: Int, wParam: WinDef.WPARAM?, lParam: WinDef.LPARAM?): WinDef.LRESULT { 82 | return when (uMsg) { 83 | WM_APPCOMMAND -> handleAppCommand(hwnd, wParam, lParam) 84 | WinUser.WM_DESTROY -> { 85 | User32.INSTANCE.PostQuitMessage(0) 86 | WinDef.LRESULT(0) 87 | } 88 | else -> User32.INSTANCE.DefWindowProc(hwnd, uMsg, wParam, lParam) 89 | } 90 | } 91 | } 92 | wc.hInstance = hInstance 93 | wc.lpszClassName = windowClass 94 | 95 | val atom = User32.INSTANCE.RegisterClassEx(wc) 96 | if (atom.toInt() == 0) { 97 | throw RuntimeException("注册窗口类失败,错误码: ${Kernel32.INSTANCE.GetLastError()}") 98 | } 99 | } 100 | 101 | private fun createWindow() { 102 | val hInstance = Kernel32.INSTANCE.GetModuleHandle(null) 103 | hwnd = User32.INSTANCE.CreateWindowEx( 104 | 0, 105 | windowClass, 106 | "SPW Media Control Window", 107 | 0, 108 | CW_USEDEFAULT, 109 | CW_USEDEFAULT, 110 | 100, 111 | 100, 112 | null, 113 | null, 114 | hInstance, 115 | null as WinDef.LPVOID? 116 | ) 117 | 118 | if (hwnd == null || hwnd!!.pointer == Pointer.NULL) { 119 | throw RuntimeException("创建窗口失败,错误码: ${Kernel32.INSTANCE.GetLastError()}") 120 | } 121 | } 122 | 123 | private fun startMessageLoop() { 124 | messageLoopThread = Thread { 125 | val msg = WinUser.MSG() 126 | while (User32.INSTANCE.GetMessage(msg, null, 0, 0) != 0) { 127 | User32.INSTANCE.TranslateMessage(msg) 128 | User32.INSTANCE.DispatchMessage(msg) 129 | } 130 | }.apply { 131 | isDaemon = true 132 | start() 133 | } 134 | } 135 | 136 | private fun registerMediaKeys() { 137 | executor.scheduleAtFixedRate({ 138 | updateSystemMediaStatus() 139 | }, 0, 1, TimeUnit.SECONDS) 140 | } 141 | 142 | private fun handleAppCommand(hwnd: WinDef.HWND?, wParam: WinDef.WPARAM?, lParam: WinDef.LPARAM?): WinDef.LRESULT { 143 | val command = (lParam!!.toInt() shr 16) and 0xFF 144 | 145 | when (command) { 146 | // 处理播放/暂停命令 147 | APPCOMMAND_MEDIA_PLAY_PAUSE -> { 148 | togglePlayback() 149 | } 150 | // 处理下一曲命令 151 | APPCOMMAND_MEDIA_NEXTTRACK -> handleNextTrack() 152 | // 处理上一曲命令 153 | APPCOMMAND_MEDIA_PREVIOUSTRACK -> handlePreviousTrack() 154 | APPCOMMAND_VOLUME_UP -> handleVolumeUp() 155 | APPCOMMAND_VOLUME_DOWN -> handleVolumeDown() 156 | APPCOMMAND_VOLUME_MUTE -> handleMute() 157 | } 158 | 159 | return WinDef.LRESULT(0) 160 | } 161 | 162 | private fun updateSystemMediaStatus() { 163 | val currentMedia = PlaybackStateHolder.currentMedia 164 | if (currentMedia != null) { 165 | // 实时更新系统媒体状态(包括播放/暂停状态) 166 | println("${if (PlaybackStateHolder.isPlaying) "正在播放" else "已暂停"}: ${currentMedia.title} - ${currentMedia.artist}") 167 | } 168 | } 169 | 170 | // 使用WorkshopApi实现播放/暂停切换 171 | private fun togglePlayback() { 172 | try { 173 | if (workshopApiAvailable) { 174 | // 尝试使用Workshop API 175 | if (PlaybackStateHolder.isPlaying) { 176 | WorkshopApi.instance.playback.pause() 177 | } else { 178 | WorkshopApi.instance.playback.play() 179 | } 180 | } else { 181 | // 回退到旧的实现方式 182 | playbackExtension.togglePlayback() 183 | // 同步更新播放状态到状态持有者 184 | PlaybackStateHolder.isPlaying = !PlaybackStateHolder.isPlaying 185 | } 186 | // 状态会在回调中自动更新 187 | } catch (e: Exception) { 188 | println("播放/暂停操作失败: ${e.message}") 189 | e.printStackTrace() 190 | } 191 | } 192 | 193 | // 实现实际下一曲逻辑 194 | fun handleNextTrack() { 195 | try { 196 | println("执行下一曲操作") 197 | 198 | if (workshopApiAvailable) { 199 | // 使用WorkshopApi的下一曲方法 200 | WorkshopApi.instance.playback.next() 201 | } else { 202 | // 回退到旧的实现方式 203 | playbackExtension.next() 204 | } 205 | 206 | // 更新当前媒体信息(假设切换后会自动更新,这里可根据实际情况调整) 207 | val newMedia = PlaybackStateHolder.currentMedia 208 | if (newMedia != null) { 209 | println("切换到下一曲: ${newMedia.title}") 210 | } 211 | } catch (e: Exception) { 212 | println("下一曲操作失败: ${e.message}") 213 | e.printStackTrace() 214 | } 215 | } 216 | 217 | // 实现实际上一曲逻辑 218 | fun handlePreviousTrack() { 219 | try { 220 | println("执行上一曲操作") 221 | 222 | if (workshopApiAvailable) { 223 | // 使用WorkshopApi的上一曲方法 224 | WorkshopApi.instance.playback.previous() 225 | } else { 226 | // 回退到旧的实现方式 227 | playbackExtension.previous() 228 | } 229 | 230 | // 更新当前媒体信息 231 | val newMedia = PlaybackStateHolder.currentMedia 232 | if (newMedia != null) { 233 | println("切换到上一曲: ${newMedia.title}") 234 | } 235 | } catch (e: Exception) { 236 | println("上一曲操作失败: ${e.message}") 237 | e.printStackTrace() 238 | } 239 | } 240 | 241 | // 改为public以便HttpServer访问 242 | fun handleVolumeUp() { 243 | val newVolume = minOf(PlaybackStateHolder.volume + 5, 100) 244 | playbackExtension.setVolume(newVolume) 245 | PlaybackStateHolder.volume = newVolume // 同步音量状态 246 | } 247 | 248 | // 改为public以便HttpServer访问 249 | fun handleVolumeDown() { 250 | val newVolume = maxOf(PlaybackStateHolder.volume - 5, 0) 251 | playbackExtension.setVolume(newVolume) 252 | PlaybackStateHolder.volume = newVolume // 同步音量状态 253 | } 254 | 255 | fun handleMute() { 256 | PlaybackStateHolder.toggleMute() 257 | playbackExtension.setVolume(PlaybackStateHolder.volume) 258 | } 259 | 260 | fun shutdown() { 261 | if (!isRunning) return 262 | 263 | isRunning = false 264 | executor.shutdown() 265 | executor.awaitTermination(1, TimeUnit.SECONDS) 266 | 267 | if (hwnd != null && hwnd!!.pointer != Pointer.NULL) { 268 | User32.INSTANCE.DestroyWindow(hwnd) 269 | hwnd = null 270 | } 271 | 272 | val hInstance = Kernel32.INSTANCE.GetModuleHandle(null) 273 | User32.INSTANCE.UnregisterClass(windowClass, hInstance) 274 | 275 | messageLoopThread?.join(1000) 276 | } 277 | 278 | interface User32 : com.sun.jna.platform.win32.User32 { 279 | override fun RegisterClassEx(lpwcx: WinUser.WNDCLASSEX): WinDef.ATOM 280 | 281 | override fun CreateWindowEx( 282 | dwExStyle: Int, 283 | lpClassName: String, 284 | lpWindowName: String, 285 | dwStyle: Int, 286 | x: Int, 287 | y: Int, 288 | nWidth: Int, 289 | nHeight: Int, 290 | hWndParent: WinDef.HWND?, 291 | hMenu: WinDef.HMENU?, 292 | hInstance: WinDef.HINSTANCE?, 293 | lpParam: WinDef.LPVOID? 294 | ): WinDef.HWND 295 | 296 | companion object { 297 | val INSTANCE: User32 = Native.load("user32", User32::class.java, W32APIOptions.DEFAULT_OPTIONS) 298 | } 299 | } 300 | } -------------------------------------------------------------------------------- /src/main/java/com/zmxl/plugin/playback/PlaybackExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 zmxl 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.zmxl.plugin.playback 17 | 18 | import com.xuncorp.spw.workshop.api.PlaybackExtensionPoint 19 | import com.xuncorp.spw.workshop.api.WorkshopApi 20 | import org.json.JSONArray 21 | import org.json.JSONObject 22 | import org.pf4j.Extension 23 | import java.net.HttpURLConnection 24 | import java.net.URL 25 | import java.net.URLEncoder 26 | import java.util.Timer 27 | import java.util.TimerTask 28 | import kotlin.concurrent.thread 29 | 30 | @Extension 31 | class SpwPlaybackExtension : PlaybackExtensionPoint { 32 | // 使用 Workshop API 实例 33 | private val workshopApi: WorkshopApi 34 | get() = WorkshopApi.instance 35 | 36 | // 添加一个标志来检测Workshop API是否可用 37 | private val workshopApiAvailable by lazy { 38 | try { 39 | // 尝试访问Workshop API的方法来检查是否可用 40 | WorkshopApi.instance.playback 41 | true 42 | } catch (e: Exception) { 43 | false 44 | } 45 | } 46 | 47 | override fun onStateChanged(state: PlaybackExtensionPoint.State) { 48 | PlaybackStateHolder.currentState = state 49 | 50 | // 打印状态值以帮助调试 51 | println("播放状态变化: ${state.name}") 52 | 53 | // 根据播放状态更新进度计时器 54 | when (state) { 55 | PlaybackExtensionPoint.State.Ready -> { 56 | if (PlaybackStateHolder.isPlaying) { 57 | PlaybackStateHolder.startPositionUpdate() 58 | } 59 | } 60 | PlaybackExtensionPoint.State.Ended -> { 61 | PlaybackStateHolder.stopPositionUpdate() 62 | } 63 | else -> {} 64 | } 65 | } 66 | 67 | override fun onIsPlayingChanged(isPlaying: Boolean) { 68 | PlaybackStateHolder.isPlaying = isPlaying 69 | 70 | // 根据播放状态更新进度计时器 71 | if (isPlaying) { 72 | PlaybackStateHolder.startPositionUpdate() 73 | } else { 74 | PlaybackStateHolder.stopPositionUpdate() 75 | } 76 | } 77 | 78 | override fun onSeekTo(position: Long) { 79 | // 设置新的播放位置 80 | PlaybackStateHolder.setPosition(position) 81 | } 82 | 83 | // 实现已弃用的 updateLyrics 方法 84 | override fun updateLyrics(mediaItem: PlaybackExtensionPoint.MediaItem): String? { 85 | return onBeforeLoadLyrics(mediaItem) 86 | } 87 | 88 | override fun onBeforeLoadLyrics(mediaItem: PlaybackExtensionPoint.MediaItem): String? { 89 | PlaybackStateHolder.currentMedia = mediaItem 90 | 91 | // 生成当前歌曲的唯一ID 92 | val songId = "${mediaItem.title}-${mediaItem.artist}-${mediaItem.album}" 93 | PlaybackStateHolder.setCurrentSongId(songId) 94 | 95 | // 清除之前的歌词缓存 96 | PlaybackStateHolder.clearCurrentLyrics() 97 | 98 | // 重置播放位置 99 | PlaybackStateHolder.resetPosition() 100 | 101 | // 不再尝试获取歌词,由LyricServlet专门处理 102 | // 只获取封面图片 103 | thread { 104 | try { 105 | // 构建搜索URL 106 | val searchQuery = "${mediaItem.title}-${mediaItem.artist}" 107 | val encodedQuery = URLEncoder.encode(searchQuery, "UTF-8") 108 | val searchUrl = "https://music.163.com/api/search/get?type=1&offset=0&limit=1&s=$encodedQuery" 109 | 110 | // 执行搜索请求 111 | val searchResult = getUrlContent(searchUrl) 112 | val searchJson = JSONObject(searchResult) 113 | 114 | if (searchJson.has("result") && !searchJson.isNull("result")) { 115 | val result = searchJson.getJSONObject("result") 116 | if (result.has("songs") && !result.isNull("songs")) { 117 | val songs = result.getJSONArray("songs") 118 | 119 | if (songs.length() > 0) { 120 | val songId = songs.getJSONObject(0).getInt("id") 121 | 122 | // 获取歌曲详细信息 123 | val songInfoUrl = "https://api.injahow.cn/meting/?type=song&id=$songId" 124 | val songInfoResult = getUrlContent(songInfoUrl) 125 | val songInfoArray = JSONArray(songInfoResult) 126 | 127 | if (songInfoArray.length() > 0) { 128 | val songInfo = songInfoArray.getJSONObject(0) 129 | PlaybackStateHolder.coverUrl = songInfo.getString("pic") 130 | println("获取封面成功: coverUrl=${PlaybackStateHolder.coverUrl}") 131 | } 132 | } 133 | } 134 | } 135 | } catch (e: Exception) { 136 | println("获取封面失败: ${e.message}") 137 | } 138 | } 139 | 140 | return null // 使用默认歌词逻辑 141 | } 142 | 143 | override fun onLyricsLineUpdated(lyricsLine: PlaybackExtensionPoint.LyricsLine?) { 144 | // 处理歌词行更新 145 | lyricsLine?.let { line -> 146 | println("歌词行更新: ${line.pureMainText} (${line.startTime}-${line.endTime})") 147 | 148 | // 修复:避免对来自不同模块的属性进行智能转换 149 | val pureSubText = line.pureSubText 150 | val combinedText = if (pureSubText != null && pureSubText.isNotEmpty()) { 151 | "${line.pureMainText}\n${pureSubText}" 152 | } else { 153 | line.pureMainText 154 | } 155 | 156 | // 将歌词行添加到缓存 157 | val lyricLine = PlaybackStateHolder.LyricLine( 158 | line.startTime, 159 | combinedText 160 | ) 161 | 162 | PlaybackStateHolder.addLyricLine(lyricLine) 163 | } 164 | } 165 | 166 | // 辅助方法:获取URL内容 167 | private fun getUrlContent(urlString: String): String { 168 | val url = URL(urlString) 169 | val conn = url.openConnection() as HttpURLConnection 170 | conn.requestMethod = "GET" 171 | conn.connectTimeout = 5000 172 | conn.readTimeout = 5000 173 | return conn.inputStream.bufferedReader().use { it.readText() } 174 | } 175 | 176 | // 音量控制 - 现在使用0-100整数 177 | fun setVolume(level: Int) { 178 | if (level in 0..100) { 179 | PlaybackStateHolder.volume = level 180 | // 这里可以添加实际的音量控制逻辑 181 | } 182 | } 183 | 184 | // 进度跳转 185 | fun seekTo(position: Long) { 186 | // 实际跳转需要调用SPW内部API 187 | PlaybackStateHolder.setPosition(position) 188 | } 189 | 190 | // 播放/暂停切换 - 使用WorkshopApi 191 | fun togglePlayback() { 192 | try { 193 | if (workshopApiAvailable) { 194 | if (PlaybackStateHolder.isPlaying) { 195 | WorkshopApi.instance.playback.pause() 196 | } else { 197 | WorkshopApi.instance.playback.play() 198 | } 199 | } else { 200 | // 回退到旧的实现方式 201 | val newState = !PlaybackStateHolder.isPlaying 202 | PlaybackStateHolder.isPlaying = newState 203 | // 这里可以添加实际的播放/暂停控制逻辑 204 | } 205 | // 状态会在回调中自动更新 206 | } catch (e: Exception) { 207 | println("播放/暂停操作失败: ${e.message}") 208 | e.printStackTrace() 209 | } 210 | } 211 | 212 | // 下一曲功能实现 - 使用WorkshopApi 213 | fun next() { 214 | try { 215 | println("执行下一曲操作") 216 | if (workshopApiAvailable) { 217 | WorkshopApi.instance.playback.next() 218 | } else { 219 | // 回退到旧的实现方式 220 | // 这里可以添加实际的下一曲控制逻辑 221 | println("下一曲操作(旧方式)") 222 | } 223 | } catch (e: Exception) { 224 | println("下一曲操作失败: ${e.message}") 225 | e.printStackTrace() 226 | } 227 | } 228 | 229 | // 上一曲功能实现 - 使用WorkshopApi 230 | fun previous() { 231 | try { 232 | println("执行上一曲操作") 233 | if (workshopApiAvailable) { 234 | WorkshopApi.instance.playback.previous() 235 | } else { 236 | // 回退到旧的实现方式 237 | // 这里可以添加实际的上一曲控制逻辑 238 | println("上一曲操作(旧方式)") 239 | } 240 | } catch (e: Exception) { 241 | println("上一曲操作失败: ${e.message}") 242 | e.printStackTrace() 243 | } 244 | } 245 | 246 | // 使用 Workshop API 的独占音频功能 247 | fun setExclusiveAudio(exclusive: Boolean) { 248 | try { 249 | workshopApi.playback.changeExclusive(exclusive) 250 | println("设置独占音频: $exclusive") 251 | } catch (e: Exception) { 252 | println("设置独占音频失败: ${e.message}") 253 | } 254 | } 255 | 256 | // 使用 Workshop API 显示提示信息 257 | fun showToast(message: String, type: WorkshopApi.Ui.ToastType = WorkshopApi.Ui.ToastType.Success) { 258 | try { 259 | workshopApi.ui.toast(message, type) 260 | println("显示提示: $message") 261 | } catch (e: Exception) { 262 | println("显示提示失败: ${e.message}") 263 | } 264 | } 265 | } -------------------------------------------------------------------------------- /src/main/java/com/zmxl/plugin/playback/PlaybackStateHolder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 zmxl 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.zmxl.plugin.playback 17 | 18 | import com.xuncorp.spw.workshop.api.PlaybackExtensionPoint 19 | import org.pf4j.Extension 20 | import java.util.* 21 | import java.util.concurrent.ConcurrentHashMap 22 | 23 | object PlaybackStateHolder { 24 | @Volatile 25 | var currentMedia: PlaybackExtensionPoint.MediaItem? = null 26 | @Volatile 27 | var isPlaying: Boolean = false 28 | @Volatile 29 | var currentState: PlaybackExtensionPoint.State = PlaybackExtensionPoint.State.Idle 30 | @Volatile 31 | var volume: Int = 100 // 音量范围改为0-100整数 32 | @Volatile 33 | var lyricUrl: String? = null 34 | @Volatile 35 | var coverUrl: String? = null 36 | 37 | // 播放进度跟踪 38 | @Volatile 39 | var currentPosition: Long = 0L 40 | private var positionUpdateTimer: Timer? = null 41 | private var lastPositionUpdateTime: Long = 0 42 | private var previousVolumeBeforeMute: Int = 100 // 静音前的音量 43 | 44 | // 存储从SPW获取的歌词行,按歌曲ID分组 45 | private val lyricsCache = ConcurrentHashMap>() 46 | 47 | // 当前歌曲ID 48 | @Volatile 49 | private var currentSongId: String? = null 50 | 51 | // 歌词访问同步锁 52 | private val lyricsLock = Any() 53 | 54 | // 开始更新播放进度 55 | fun startPositionUpdate() { 56 | stopPositionUpdate() // 确保之前的定时器已停止 57 | 58 | positionUpdateTimer = Timer(true) // 使用守护线程 59 | lastPositionUpdateTime = System.currentTimeMillis() 60 | 61 | positionUpdateTimer?.scheduleAtFixedRate(object : TimerTask() { 62 | override fun run() { 63 | if (isPlaying) { 64 | val now = System.currentTimeMillis() 65 | val elapsed = now - lastPositionUpdateTime 66 | currentPosition += elapsed 67 | lastPositionUpdateTime = now 68 | } 69 | } 70 | }, 0, 100) // 每100毫秒更新一次 71 | } 72 | 73 | // 停止更新播放进度 74 | fun stopPositionUpdate() { 75 | positionUpdateTimer?.cancel() 76 | positionUpdateTimer = null 77 | } 78 | 79 | // 设置播放位置(如跳转时) 80 | fun setPosition(position: Long) { 81 | currentPosition = position 82 | lastPositionUpdateTime = System.currentTimeMillis() 83 | } 84 | 85 | // 重置播放位置 86 | fun resetPosition() { 87 | currentPosition = 0L 88 | lastPositionUpdateTime = System.currentTimeMillis() 89 | } 90 | 91 | // 静音/取消静音 92 | fun toggleMute() { 93 | if (volume > 0) { 94 | // 保存当前音量并静音 95 | previousVolumeBeforeMute = volume 96 | volume = 0 97 | } else { 98 | // 恢复静音前的音量 99 | volume = previousVolumeBeforeMute 100 | } 101 | } 102 | 103 | // 添加方法:设置当前歌曲ID 104 | fun setCurrentSongId(songId: String) { 105 | currentSongId = songId 106 | // 如果缓存中没有这首歌的歌词,初始化一个空列表 107 | lyricsCache.putIfAbsent(songId, mutableListOf()) 108 | } 109 | 110 | // 添加方法:添加歌词行 111 | fun addLyricLine(line: LyricLine) { 112 | currentSongId?.let { songId -> 113 | synchronized(lyricsLock) { 114 | val lines = lyricsCache.getOrPut(songId) { mutableListOf() } 115 | 116 | // 检查是否已存在相同时间的歌词行 117 | val existingIndex = lines.indexOfFirst { it.time == line.time } 118 | 119 | if (existingIndex >= 0) { 120 | // 更新现有行 121 | lines[existingIndex] = line 122 | } else { 123 | // 添加新行并保持按时间排序 124 | lines.add(line) 125 | // 使用更安全的排序方法 126 | val sortedLines = lines.sortedBy { it.time }.toMutableList() 127 | lines.clear() 128 | lines.addAll(sortedLines) 129 | } 130 | } 131 | } 132 | } 133 | 134 | // 添加方法:获取当前行和下一行歌词 135 | fun getCurrentAndNextLyrics(currentPosition: Long): Pair { 136 | currentSongId?.let { songId -> 137 | synchronized(lyricsLock) { 138 | val lines = lyricsCache[songId] ?: return Pair(null, null) 139 | 140 | // 找到当前时间对应的歌词行 141 | var currentLine: LyricLine? = null 142 | var nextLine: LyricLine? = null 143 | 144 | for (i in lines.indices) { 145 | if (lines[i].time > currentPosition) { 146 | nextLine = lines[i] 147 | if (i > 0) { 148 | currentLine = lines[i - 1] 149 | } 150 | break 151 | } 152 | 153 | // 如果是最后一行 154 | if (i == lines.size - 1 && lines[i].time <= currentPosition) { 155 | currentLine = lines[i] 156 | } 157 | } 158 | 159 | return Pair(currentLine, nextLine) 160 | } 161 | } 162 | 163 | return Pair(null, null) 164 | } 165 | 166 | // 添加方法:清除当前歌曲的歌词缓存 167 | fun clearCurrentLyrics() { 168 | currentSongId?.let { songId -> 169 | synchronized(lyricsLock) { 170 | lyricsCache.remove(songId) 171 | } 172 | } 173 | } 174 | 175 | // 添加方法:获取当前歌曲的所有歌词(用于调试) 176 | fun getAllLyrics(): List { 177 | currentSongId?.let { songId -> 178 | synchronized(lyricsLock) { 179 | return lyricsCache[songId] ?: emptyList() 180 | } 181 | } 182 | return emptyList() 183 | } 184 | 185 | // 歌词行数据类 186 | data class LyricLine(val time: Long, val text: String) 187 | } 188 | -------------------------------------------------------------------------------- /src/main/java/com/zmxl/plugin/server/HttpServer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025 zmxl 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package com.zmxl.plugin.server 17 | import com.google.gson.Gson 18 | import com.sun.jna.Native 19 | import com.sun.jna.platform.win32.User32 20 | import com.zmxl.plugin.control.SmtcController 21 | import com.zmxl.plugin.playback.PlaybackStateHolder 22 | import org.eclipse.jetty.server.Server 23 | import org.eclipse.jetty.servlet.ServletContextHandler 24 | import org.eclipse.jetty.servlet.ServletHolder 25 | import jakarta.servlet.http.HttpServlet 26 | import jakarta.servlet.http.HttpServletRequest 27 | import jakarta.servlet.http.HttpServletResponse 28 | import java.io.IOException 29 | import java.io.InputStream 30 | import java.io.PrintWriter 31 | import java.net.HttpURLConnection 32 | import java.net.URL 33 | import java.net.URLEncoder 34 | import java.util.Base64 35 | import java.util.concurrent.Executors 36 | import org.json.JSONArray 37 | import org.json.JSONObject 38 | import org.jaudiotagger.audio.AudioFile 39 | import org.jaudiotagger.audio.AudioFileIO 40 | import org.jaudiotagger.tag.FieldKey 41 | import org.jaudiotagger.tag.Tag 42 | import java.io.File 43 | import java.nio.charset.Charset 44 | class HttpServer(private val port: Int) { 45 | private lateinit var server: Server 46 | init { 47 | SmtcController.init() 48 | } 49 | fun start() { 50 | server = Server(port) 51 | val context = ServletContextHandler(ServletContextHandler.SESSIONS) 52 | context.contextPath = "/" 53 | server.handler = context 54 | context.setAttribute("httpServer", this) 55 | context.addServlet(ServletHolder(NowPlayingServlet()), "/api/now-playing") 56 | context.addServlet(ServletHolder(PlayPauseServlet()), "/api/play-pause") 57 | context.addServlet(ServletHolder(NextTrackServlet()), "/api/next-track") 58 | context.addServlet(ServletHolder(PreviousTrackServlet()), "/api/previous-track") 59 | context.addServlet(ServletHolder(VolumeUpServlet()), "/api/volume/up") 60 | context.addServlet(ServletHolder(VolumeDownServlet()), "/api/volume/down") 61 | context.addServlet(ServletHolder(MuteServlet()), "/api/mute") 62 | context.addServlet(ServletHolder(Lyric163Servlet()), "/api/lyric163") 63 | context.addServlet(ServletHolder(LyricQQServlet()), "/api/lyricqq") 64 | context.addServlet(ServletHolder(LyricKugouServlet()), "/api/lyrickugou") 65 | context.addServlet(ServletHolder(LyricFileServlet()), "/api/lyric") 66 | context.addServlet(ServletHolder(LyricFileLrcServlet()), "/api/lyricfile") 67 | context.addServlet(ServletHolder(PicServlet()), "/api/pic") 68 | context.addServlet(ServletHolder(CurrentPositionServlet()), "/api/current-position") 69 | context.addServlet(ServletHolder(object : HttpServlet() { 70 | @Throws(IOException::class) 71 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 72 | val htmlStream: InputStream? = javaClass.getResourceAsStream("/web/index.html") 73 | if (htmlStream != null) { 74 | resp.contentType = "text/html;charset=UTF-8" 75 | resp.characterEncoding = "UTF-8" 76 | htmlStream.copyTo(resp.outputStream) 77 | } else { 78 | resp.status = HttpServletResponse.SC_NOT_FOUND 79 | resp.writer.write("HTML resource not found") 80 | } 81 | } 82 | }), "/*") 83 | try { 84 | server.start() 85 | println("HTTP服务器已启动,端口: $port") 86 | } catch (e: Exception) { 87 | println("HTTP服务器启动失败: ${e.message}") 88 | e.printStackTrace() 89 | } 90 | } 91 | fun stop() { 92 | try { 93 | server.stop() 94 | SmtcController.shutdown() 95 | println("HTTP服务器已停止") 96 | } catch (e: Exception) { 97 | println("HTTP服务器停止失败: ${e.message}") 98 | e.printStackTrace() 99 | } 100 | } 101 | /** 102 | * 获取当前播放信息API 103 | */ 104 | class NowPlayingServlet : HttpServlet() { 105 | @Throws(IOException::class) 106 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 107 | resp.contentType = "application/json;charset=UTF-8" 108 | val responseData = mapOf( 109 | "status" to "success", 110 | "title" to PlaybackStateHolder.currentMedia?.title, 111 | "artist" to PlaybackStateHolder.currentMedia?.artist, 112 | "album" to PlaybackStateHolder.currentMedia?.album, 113 | "isPlaying" to PlaybackStateHolder.isPlaying, 114 | "position" to PlaybackStateHolder.currentPosition, 115 | "volume" to PlaybackStateHolder.volume, 116 | "timestamp" to System.currentTimeMillis() 117 | ) 118 | PrintWriter(resp.writer).use { out -> 119 | out.print(Gson().toJson(responseData)) 120 | } 121 | } 122 | } 123 | /** 124 | * 播放/暂停切换API 125 | */ 126 | class PlayPauseServlet : HttpServlet() { 127 | @Throws(IOException::class) 128 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 129 | resp.contentType = "application/json;charset=UTF-8" 130 | try { 131 | val httpServer = req.servletContext.getAttribute("httpServer") as HttpServer 132 | httpServer.sendMediaKeyEvent(0xB3) 133 | Thread.sleep(100) 134 | val response = mapOf( 135 | "status" to "success", 136 | "action" to "play_pause_toggled", 137 | "isPlaying" to PlaybackStateHolder.isPlaying, 138 | "message" to if (PlaybackStateHolder.isPlaying) "已开始播放" else "已暂停" 139 | ) 140 | resp.writer.write(Gson().toJson(response)) 141 | } catch (e: Exception) { 142 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 143 | resp.writer.write(Gson().toJson(mapOf( 144 | "status" to "error", 145 | "message" to "播放/暂停操作失败: ${e.message}" 146 | ))) 147 | } 148 | } 149 | } 150 | /** 151 | * 下一曲API 152 | */ 153 | class NextTrackServlet : HttpServlet() { 154 | @Throws(IOException::class) 155 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 156 | resp.contentType = "application/json;charset=UTF-8" 157 | try { 158 | SmtcController.handleNextTrack() 159 | val httpServer = req.servletContext.getAttribute("httpServer") as HttpServer 160 | httpServer.sendMediaKeyEvent(0xB0) 161 | Thread.sleep(100) 162 | val newMedia = PlaybackStateHolder.currentMedia 163 | val response = mapOf( 164 | "status" to "success", 165 | "action" to "next_track", 166 | "newTrack" to (newMedia?.title ?: "未知曲目"), 167 | "message" to "已切换到下一曲" 168 | ) 169 | resp.writer.write(Gson().toJson(response)) 170 | } catch (e: Exception) { 171 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 172 | resp.writer.write(Gson().toJson(mapOf( 173 | "status" to "error", 174 | "message" to "下一曲操作失败: ${e.message}" 175 | ))) 176 | } 177 | } 178 | } 179 | /** 180 | * 上一曲API 181 | */ 182 | class PreviousTrackServlet : HttpServlet() { 183 | @Throws(IOException::class) 184 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 185 | resp.contentType = "application/json;charset=UTF-8" 186 | try { 187 | SmtcController.handlePreviousTrack() 188 | val httpServer = req.servletContext.getAttribute("httpServer") as HttpServer 189 | httpServer.sendMediaKeyEvent(0xB1) 190 | Thread.sleep(100) 191 | val newMedia = PlaybackStateHolder.currentMedia 192 | val response = mapOf( 193 | "status" to "success", 194 | "action" to "previous_track", 195 | "newTrack" to (newMedia?.title ?: "未知曲目"), 196 | "message" to "已切换到上一曲" 197 | ) 198 | resp.writer.write(Gson().toJson(response)) 199 | } catch (e: Exception) { 200 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 201 | resp.writer.write(Gson().toJson(mapOf( 202 | "status" to "error", 203 | "message" to "上一曲操作失败: ${e.message}" 204 | ))) 205 | } 206 | } 207 | } 208 | /** 209 | * 音量增加API 210 | */ 211 | class VolumeUpServlet : HttpServlet() { 212 | @Throws(IOException::class) 213 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 214 | resp.contentType = "application/json;charset=UTF-8" 215 | try { 216 | SmtcController.handleVolumeUp() 217 | val httpServer = req.servletContext.getAttribute("httpServer") as HttpServer 218 | httpServer.sendMediaKeyEvent(0xAF) 219 | Thread.sleep(50) 220 | val response = mapOf( 221 | "status" to "success", 222 | "action" to "volume_up", 223 | "currentVolume" to PlaybackStateHolder.volume, 224 | "message" to "音量已增加" 225 | ) 226 | resp.writer.write(Gson().toJson(response)) 227 | } catch (e: Exception) { 228 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 229 | resp.writer.write(Gson().toJson(mapOf( 230 | "status" to "error", 231 | "message" to "音量增加操作失败: ${e.message}" 232 | ))) 233 | } 234 | } 235 | } 236 | /** 237 | * 音量减少API 238 | */ 239 | class VolumeDownServlet : HttpServlet() { 240 | @Throws(IOException::class) 241 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 242 | resp.contentType = "application/json;charset=UTF-8" 243 | try { 244 | SmtcController.handleVolumeDown() 245 | val httpServer = req.servletContext.getAttribute("httpServer") as HttpServer 246 | httpServer.sendMediaKeyEvent(0xAE) 247 | Thread.sleep(50) 248 | val response = mapOf( 249 | "status" to "success", 250 | "action" to "volume_down", 251 | "currentVolume" to PlaybackStateHolder.volume, 252 | "message" to "音量已减少" 253 | ) 254 | resp.writer.write(Gson().toJson(response)) 255 | } catch (e: Exception) { 256 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 257 | resp.writer.write(Gson().toJson(mapOf( 258 | "status" to "error", 259 | "message" to "音量减少操作失败: ${e.message}" 260 | ))) 261 | } 262 | } 263 | } 264 | /** 265 | * 静音切换API 266 | */ 267 | class MuteServlet : HttpServlet() { 268 | @Throws(IOException::class) 269 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 270 | resp.contentType = "application/json;charset=UTF-8" 271 | try { 272 | SmtcController.handleMute() 273 | val httpServer = req.servletContext.getAttribute("httpServer") as HttpServer 274 | httpServer.sendMediaKeyEvent(0xAD) 275 | Thread.sleep(50) 276 | val isMuted = PlaybackStateHolder.volume == 0 277 | val response = mapOf( 278 | "status" to "success", 279 | "action" to "mute_toggle", 280 | "isMuted" to isMuted, 281 | "message" to if (isMuted) "已静音" else "已取消静音" 282 | ) 283 | resp.writer.write(Gson().toJson(response)) 284 | } catch (e: Exception) { 285 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 286 | resp.writer.write(Gson().toJson(mapOf( 287 | "status" to "error", 288 | "message" to "静音操作失败: ${e.message}" 289 | ))) 290 | } 291 | } 292 | } 293 | /** 294 | * 歌词API - JAudioTagger (专门优化USLT帧提取) 295 | */ 296 | class LyricFileServlet : HttpServlet() { 297 | private val gson = Gson() 298 | @Throws(IOException::class) 299 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 300 | resp.contentType = "application/json;charset=UTF-8" 301 | val currentMedia = PlaybackStateHolder.currentMedia 302 | if (currentMedia == null || currentMedia.path.isNullOrBlank()) { 303 | resp.status = HttpServletResponse.SC_BAD_REQUEST 304 | resp.writer.write(gson.toJson(mapOf( 305 | "status" to "error", 306 | "message" to "没有当前播放媒体或媒体路径为空" 307 | ))) 308 | return 309 | } 310 | val filePath = currentMedia.path 311 | println("使用当前播放媒体路径: $filePath") 312 | try { 313 | val file = File(filePath) 314 | if (!file.exists() || !file.isFile) { 315 | resp.status = HttpServletResponse.SC_NOT_FOUND 316 | resp.writer.write(gson.toJson(mapOf( 317 | "status" to "error", 318 | "message" to "文件不存在: $filePath" 319 | ))) 320 | return 321 | } 322 | val extension = file.extension.lowercase() 323 | val supportedFormats = listOf("mp3", "flac", "wav", "ogg", "m4a", "aac", "wma", "opus") 324 | if (!supportedFormats.contains(extension)) { 325 | resp.status = HttpServletResponse.SC_BAD_REQUEST 326 | resp.writer.write(gson.toJson(mapOf( 327 | "status" to "error", 328 | "message" to "不支持的音频文件格式: $extension. 支持格式: ${supportedFormats.joinToString()}" 329 | ))) 330 | return 331 | } 332 | val lyrics = extractLyricsFromFile(file) 333 | if (lyrics.isNotBlank()) { 334 | val response = mapOf( 335 | "status" to "success", 336 | "lyric" to lyrics, 337 | "source" to "file_metadata", 338 | "file" to filePath, 339 | "format" to extension 340 | ) 341 | resp.writer.write(gson.toJson(response)) 342 | } else { 343 | resp.status = HttpServletResponse.SC_NOT_FOUND 344 | resp.writer.write(gson.toJson(mapOf( 345 | "status" to "error", 346 | "message" to "文件中未找到歌词元数据", 347 | "file" to filePath, 348 | "format" to extension 349 | ))) 350 | } 351 | } catch (e: Exception) { 352 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 353 | resp.writer.write(gson.toJson(mapOf( 354 | "status" to "error", 355 | "message" to "提取文件歌词失败: ${e.message}" 356 | ))) 357 | e.printStackTrace() 358 | } 359 | } 360 | /** 361 | * 使用JAudioTagger从音频文件中提取歌词 362 | */ 363 | private fun extractLyricsFromFile(file: File): String { 364 | try { 365 | val audioFile = AudioFileIO.read(file) 366 | val tag = audioFile.tag 367 | if (tag == null) { 368 | println("文件没有标签信息: ${file.name}") 369 | return "" 370 | } 371 | val usltLyrics = extractUSLTFrameDirectly(tag) 372 | if (usltLyrics.isNotBlank()) { 373 | println("成功从USLT帧提取歌词") 374 | return usltLyrics 375 | } 376 | return findLyricsInStandardFields(tag, file.extension) 377 | } catch (e: Exception) { 378 | println("使用JAudioTagger读取文件失败: ${e.message}") 379 | return "" 380 | } 381 | } 382 | /** 383 | * 直接提取USLT帧内容 384 | */ 385 | private fun extractUSLTFrameDirectly(tag: Tag): String { 386 | try { 387 | if (tag.toString().contains("ID3v2")) { 388 | return extractFromID3v2Tag(tag) 389 | } 390 | return extractUSLTByFieldIteration(tag) 391 | } catch (e: Exception) { 392 | println("直接提取USLT帧失败: ${e.message}") 393 | return "" 394 | } 395 | } 396 | /** 397 | * 从ID3v2标签提取USLT 398 | */ 399 | private fun extractFromID3v2Tag(tag: Tag): String { 400 | try { 401 | val tagClass = tag.javaClass 402 | val getFirstFieldMethod = tagClass.methods.find { 403 | it.name == "getFirstField" && it.parameterCount == 1 404 | } 405 | if (getFirstFieldMethod != null) { 406 | val usltIdentifiers = listOf("USLT", "UNSYNCED LYRICS", "UNSYNCED_LYRICS", "ULT") 407 | for (identifier in usltIdentifiers) { 408 | try { 409 | val field = getFirstFieldMethod.invoke(tag, identifier) 410 | if (field != null) { 411 | val fieldString = field.toString() 412 | println("找到USLT字段 [$identifier]: $fieldString") 413 | val lyricContent = extractLyricContentFromField(field) 414 | if (lyricContent.isNotBlank()) { 415 | return lyricContent 416 | } 417 | return fieldString 418 | } 419 | } catch (e: Exception) { 420 | println("尝试标识符 '$identifier' 失败: ${e.message}") 421 | } 422 | } 423 | } 424 | val getFieldsMethod = tagClass.methods.find { 425 | it.name == "getFields" && it.parameterCount == 1 426 | } 427 | if (getFieldsMethod != null) { 428 | val fields = getFieldsMethod.invoke(tag, "USLT") as? List<*> 429 | if (fields != null && fields.isNotEmpty()) { 430 | val lyricsBuilder = StringBuilder() 431 | for (field in fields) { 432 | if (field != null) { 433 | val content = extractLyricContentFromField(field) 434 | if (content.isNotBlank()) { 435 | lyricsBuilder.append(content).append("\n") 436 | } else { 437 | lyricsBuilder.append(field.toString()).append("\n") 438 | } 439 | } 440 | } 441 | val result = lyricsBuilder.toString().trim() 442 | if (result.isNotBlank()) { 443 | return result 444 | } 445 | } 446 | } 447 | } catch (e: Exception) { 448 | println("从ID3v2标签提取USLT失败: ${e.message}") 449 | } 450 | return "" 451 | } 452 | /** 453 | * 从字段对象中提取歌词内容 454 | */ 455 | private fun extractLyricContentFromField(field: Any): String { 456 | try { 457 | val getContentMethod = field.javaClass.methods.find { it.name == "getContent" } 458 | if (getContentMethod != null) { 459 | val content = getContentMethod.invoke(field) as? String 460 | if (!content.isNullOrBlank()) { 461 | return content 462 | } 463 | } 464 | val fieldString = field.toString() 465 | return cleanUSLTContent(fieldString) 466 | } catch (e: Exception) { 467 | println("提取字段内容失败: ${e.message}") 468 | return "" 469 | } 470 | } 471 | /** 472 | * 清理USLT内容,移除框架标识符 473 | */ 474 | private fun cleanUSLTContent(rawContent: String): String { 475 | var content = rawContent 476 | val prefixes = listOf( 477 | "USLT:", 478 | "USLT=", 479 | "Unsynchronised lyric:", 480 | "Unsynchronized lyric:", 481 | "Unsynchronized lyric/text transcription:" 482 | ) 483 | prefixes.forEach { prefix -> 484 | if (content.startsWith(prefix, ignoreCase = true)) { 485 | content = content.substring(prefix.length).trim() 486 | } 487 | } 488 | if (content.length >= 5 && content.substring(3, 5) == "\u0000") { 489 | content = content.substring(5) 490 | } 491 | return content.trim() 492 | } 493 | /** 494 | * 通过字段迭代查找USLT 495 | */ 496 | private fun extractUSLTByFieldIteration(tag: Tag): String { 497 | val lyricsBuilder = StringBuilder() 498 | try { 499 | val fieldsMethod = tag.javaClass.methods.find { it.name == "getFields" } 500 | if (fieldsMethod != null) { 501 | val fields = fieldsMethod.invoke(tag) as? List<*> 502 | fields?.forEach { field -> 503 | if (field != null) { 504 | val fieldString = field.toString() 505 | if (fieldString.contains("USLT", ignoreCase = true) || 506 | fieldString.contains("UNSYNCED", ignoreCase = true)) { 507 | println("找到可能的歌词字段: $fieldString") 508 | val content = extractLyricContentFromField(field) 509 | if (content.isNotBlank()) { 510 | lyricsBuilder.append(content).append("\n") 511 | } else { 512 | lyricsBuilder.append(fieldString).append("\n") 513 | } 514 | } 515 | } 516 | } 517 | } 518 | } catch (e: Exception) { 519 | println("字段迭代查找USLT失败: ${e.message}") 520 | } 521 | return lyricsBuilder.toString().trim() 522 | } 523 | /** 524 | * 在标准字段中查找歌词 525 | */ 526 | private fun findLyricsInStandardFields(tag: Tag, fileExtension: String): String { 527 | val lyricFields = listOf( 528 | FieldKey.LYRICS, 529 | FieldKey.LYRICIST, 530 | FieldKey.COMMENT 531 | ) 532 | for (field in lyricFields) { 533 | try { 534 | if (tag.hasField(field)) { 535 | val value = tag.getFirst(field) 536 | if (value.isNotBlank()) { 537 | println("找到标准歌词字段: $field = $value") 538 | return value 539 | } 540 | } 541 | } catch (e: Exception) { 542 | println("读取字段 $field 失败: ${e.message}") 543 | } 544 | } 545 | return "" 546 | } 547 | } 548 | /** 549 | * 本地LRC歌词文件API 550 | * 检查当前播放媒体所在目录是否存在同名的.lrc文件,并返回其内容 551 | */ 552 | class LyricFileLrcServlet : HttpServlet() { 553 | private val gson = Gson() 554 | @Throws(IOException::class) 555 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 556 | resp.contentType = "application/json;charset=UTF-8" 557 | val currentMedia = PlaybackStateHolder.currentMedia 558 | if (currentMedia == null || currentMedia.path.isNullOrBlank()) { 559 | resp.status = HttpServletResponse.SC_BAD_REQUEST 560 | resp.writer.write(gson.toJson(mapOf( 561 | "status" to "error", 562 | "message" to "没有当前播放媒体或媒体路径为空" 563 | ))) 564 | return 565 | } 566 | val mediaPath = currentMedia.path 567 | println("当前播放媒体路径: $mediaPath") 568 | try { 569 | val lrcFile = findLrcFile(mediaPath) 570 | if (lrcFile != null && lrcFile.exists() && lrcFile.isFile) { 571 | println("找到LRC歌词文件: ${lrcFile.absolutePath}") 572 | val lyricContent = readLrcFile(lrcFile) 573 | if (lyricContent.isNotBlank()) { 574 | val response = mapOf( 575 | "status" to "success", 576 | "lyric" to lyricContent, 577 | "source" to "local_lrc_file", 578 | "file" to lrcFile.absolutePath, 579 | "fileSize" to lrcFile.length(), 580 | "message" to "成功从本地LRC文件加载歌词" 581 | ) 582 | resp.writer.write(gson.toJson(response)) 583 | } else { 584 | resp.status = HttpServletResponse.SC_NOT_FOUND 585 | resp.writer.write(gson.toJson(mapOf( 586 | "status" to "error", 587 | "message" to "LRC文件为空或无法读取", 588 | "file" to lrcFile.absolutePath 589 | ))) 590 | } 591 | } else { 592 | val searchedPath = generateLrcFilePath(mediaPath) 593 | resp.status = HttpServletResponse.SC_NOT_FOUND 594 | resp.writer.write(gson.toJson(mapOf( 595 | "status" to "error", 596 | "message" to "未找到同名的LRC歌词文件", 597 | "searchedPath" to searchedPath, 598 | "suggestion" to "请确保LRC文件与音频文件在同一目录且文件名相同" 599 | ))) 600 | } 601 | } catch (e: Exception) { 602 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 603 | resp.writer.write(gson.toJson(mapOf( 604 | "status" to "error", 605 | "message" to "查找或读取LRC文件失败: ${e.message}" 606 | ))) 607 | e.printStackTrace() 608 | } 609 | } 610 | /** 611 | * 根据媒体文件路径查找对应的LRC文件 612 | */ 613 | private fun findLrcFile(mediaPath: String): File? { 614 | val mediaFile = File(mediaPath) 615 | if (!mediaFile.exists()) { 616 | return null 617 | } 618 | val lrcFilePath = generateLrcFilePath(mediaPath) 619 | val lrcFile = File(lrcFilePath) 620 | if (lrcFile.exists() && lrcFile.isFile) { 621 | return lrcFile 622 | } 623 | return findAlternativeLrcFiles(mediaFile) 624 | } 625 | /** 626 | * 生成LRC文件路径(将原文件扩展名替换为.lrc) 627 | */ 628 | private fun generateLrcFilePath(mediaPath: String): String { 629 | val mediaFile = File(mediaPath) 630 | val parentDir = mediaFile.parent 631 | val fileNameWithoutExt = mediaFile.nameWithoutExtension 632 | return "$parentDir${File.separator}$fileNameWithoutExt.lrc" 633 | } 634 | /** 635 | * 查找其他可能的LRC文件变体 636 | */ 637 | private fun findAlternativeLrcFiles(mediaFile: File): File? { 638 | val parentDir = mediaFile.parent 639 | val baseName = mediaFile.nameWithoutExtension 640 | val possibleNames = listOf( 641 | "$baseName.lrc", 642 | "${baseName.replace(" - ", ".")}.lrc", 643 | "${baseName.replace(" ", "_")}.lrc", 644 | "${baseName.replace(" ", "")}.lrc", 645 | "$baseName.lyric", 646 | "$baseName.txt" 647 | ) 648 | for (fileName in possibleNames) { 649 | val lrcFile = File(parentDir, fileName) 650 | if (lrcFile.exists() && lrcFile.isFile) { 651 | println("找到备选LRC文件: ${lrcFile.absolutePath}") 652 | return lrcFile 653 | } 654 | } 655 | return null 656 | } 657 | /** 658 | * 读取LRC文件内容 659 | */ 660 | private fun readLrcFile(lrcFile: File): String { 661 | return try { 662 | lrcFile.readText(Charsets.UTF_8) 663 | } catch (e: Exception) { 664 | println("UTF-8读取失败,尝试其他编码: ${e.message}") 665 | val encodings = listOf("GBK", "GB2312", "ISO-8859-1", "Windows-1252") 666 | for (encoding in encodings) { 667 | try { 668 | return lrcFile.readText(Charset.forName(encoding)) 669 | } catch (e: Exception) { 670 | println("编码 $encoding 读取失败: ${e.message}") 671 | } 672 | } 673 | "" 674 | } 675 | } 676 | } 677 | /** 678 | * 网易云音乐网络歌词API 679 | */ 680 | class Lyric163Servlet : HttpServlet() { 681 | private val gson = Gson() 682 | @Throws(IOException::class) 683 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 684 | resp.contentType = "application/json;charset=UTF-8" 685 | val media = PlaybackStateHolder.currentMedia 686 | if (media == null) { 687 | resp.status = HttpServletResponse.SC_NOT_FOUND 688 | resp.writer.write(gson.toJson(mapOf( 689 | "status" to "error", 690 | "message" to "没有当前媒体信息" 691 | ))) 692 | return 693 | } 694 | try { 695 | val lyricContent = tryGetLyricFromMultipleSources(media.title, media.artist) 696 | if (lyricContent != null && lyricContent.isNotBlank()) { 697 | val response = mapOf( 698 | "status" to "success", 699 | "lyric" to lyricContent, 700 | "source" to "network" 701 | ) 702 | resp.writer.write(gson.toJson(response)) 703 | } else { 704 | resp.status = HttpServletResponse.SC_NOT_FOUND 705 | resp.writer.write(gson.toJson(mapOf( 706 | "status" to "error", 707 | "message" to "未找到网络歌词" 708 | ))) 709 | } 710 | } catch (e: Exception) { 711 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 712 | resp.writer.write(gson.toJson(mapOf( 713 | "status" to "error", 714 | "message" to "获取网络歌词失败: ${e.message}" 715 | ))) 716 | } 717 | } 718 | private fun tryGetLyricFromMultipleSources(title: String?, artist: String?): String? { 719 | if (title.isNullOrBlank()) return null 720 | val lyric1 = getLyricFromNeteaseOfficial(title, artist) 721 | if (lyric1 != null) return lyric1 722 | return null 723 | } 724 | private fun getLyricFromNeteaseOfficial(title: String?, artist: String?): String? { 725 | try { 726 | val searchQuery = if (!artist.isNullOrBlank()) "$title $artist" else title 727 | val encodedQuery = URLEncoder.encode(searchQuery, "UTF-8") 728 | val searchUrl = "https://music.163.com/api/search/get?type=1&offset=0&limit=1&s=$encodedQuery" 729 | val searchResult = getUrlContentWithHeaders(searchUrl, mapOf( 730 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 731 | "Referer" to "https://music.163.com/" 732 | )) 733 | val searchJson = JSONObject(searchResult) 734 | if (!searchJson.has("result") || searchJson.isNull("result")) { 735 | return null 736 | } 737 | val result = searchJson.getJSONObject("result") 738 | if (!result.has("songs") || result.isNull("songs")) { 739 | return null 740 | } 741 | val songs = result.getJSONArray("songs") 742 | if (songs.length() > 0) { 743 | val songId = songs.getJSONObject(0).getInt("id") 744 | val lyricUrl = "https://music.163.com/api/song/lyric?id=$songId&lv=1&tv=-1" 745 | val lyricResult = getUrlContentWithHeaders(lyricUrl, mapOf( 746 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 747 | "Referer" to "https://music.163.com/" 748 | )) 749 | val lyricObj = JSONObject(lyricResult) 750 | if (lyricObj.has("lrc") && !lyricObj.isNull("lrc") && 751 | lyricObj.getJSONObject("lrc").has("lyric")) { 752 | return lyricObj.getJSONObject("lrc").getString("lyric") 753 | } 754 | } 755 | } catch (e: Exception) { 756 | println("从网易云官方API获取歌词失败: ${e.message}") 757 | } 758 | return null 759 | } 760 | private fun getUrlContentWithHeaders(urlString: String, headers: Map): String { 761 | val url = URL(urlString) 762 | val conn = url.openConnection() as HttpURLConnection 763 | conn.requestMethod = "GET" 764 | conn.connectTimeout = 3000 765 | conn.readTimeout = 3000 766 | headers.forEach { (key, value) -> 767 | conn.setRequestProperty(key, value) 768 | } 769 | return conn.inputStream.bufferedReader().use { it.readText() } 770 | } 771 | private fun getUrlContent(urlString: String): String { 772 | return getUrlContentWithHeaders(urlString, emptyMap()) 773 | } 774 | } 775 | /** 776 | * QQ音乐歌词API 777 | */ 778 | class LyricQQServlet : HttpServlet() { 779 | private val gson = Gson() 780 | @Throws(IOException::class) 781 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 782 | resp.contentType = "application/json;charset=UTF-8" 783 | val media = PlaybackStateHolder.currentMedia 784 | if (media == null) { 785 | resp.status = HttpServletResponse.SC_NOT_FOUND 786 | resp.writer.write(gson.toJson(mapOf( 787 | "status" to "error", 788 | "message" to "没有当前媒体信息" 789 | ))) 790 | return 791 | } 792 | try { 793 | val lyricContent = getLyricFromQQMusic(media.title, media.artist) 794 | if (lyricContent != null && lyricContent.isNotBlank()) { 795 | val response = mapOf( 796 | "status" to "success", 797 | "lyric" to lyricContent, 798 | "source" to "qqmusic" 799 | ) 800 | resp.writer.write(gson.toJson(response)) 801 | } else { 802 | resp.status = HttpServletResponse.SC_NOT_FOUND 803 | resp.writer.write(gson.toJson(mapOf( 804 | "status" to "error", 805 | "message" to "未找到QQ音乐歌词" 806 | ))) 807 | } 808 | } catch (e: Exception) { 809 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 810 | resp.writer.write(gson.toJson(mapOf( 811 | "status" to "error", 812 | "message" to "获取QQ音乐歌词失败: ${e.message}" 813 | ))) 814 | } 815 | } 816 | private fun getLyricFromQQMusic(title: String?, artist: String?): String? { 817 | if (title.isNullOrBlank()) return null 818 | try { 819 | val searchQuery = if (!artist.isNullOrBlank()) "$title $artist" else title 820 | val encodedQuery = URLEncoder.encode(searchQuery, "UTF-8") 821 | val searchUrl = "https://c.y.qq.com/soso/fcgi-bin/music_search_new_platform?format=json&p=1&n=1&w=$encodedQuery" 822 | val searchResult = getUrlContentWithHeaders(searchUrl, mapOf( 823 | "User-AAgent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 824 | "Referer" to "https://y.qq.com/" 825 | )) 826 | val searchJson = JSONObject(searchResult) 827 | if (!searchJson.has("data") || searchJson.isNull("data") || 828 | !searchJson.getJSONObject("data").has("song") || 829 | searchJson.getJSONObject("data").isNull("song") || 830 | searchJson.getJSONObject("data").getJSONObject("song").getJSONArray("list").length() == 0) { 831 | return null 832 | } 833 | val songList = searchJson.getJSONObject("data").getJSONObject("song").getJSONArray("list") 834 | if (songList.length() > 0) { 835 | val fField = songList.getJSONObject(0).getString("f") 836 | val fParts = fField.split("|") 837 | if (fParts.size > 0) { 838 | val songId = fParts[0] 839 | val mid = getSongMidFromId(songId) 840 | if (mid != null) { 841 | val lyricUrl = "https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?format=json&nobase64=1&songmid=$mid" 842 | val lyricResult = getUrlContentWithHeaders(lyricUrl, mapOf( 843 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 844 | "Referer" to "https://y.qq.com/portal/player.html" 845 | )) 846 | val lyricObj = JSONObject(lyricResult) 847 | if (lyricObj.has("lyric") && !lyricObj.isNull("lyric")) { 848 | return lyricObj.getString("lyric") 849 | } 850 | } 851 | } 852 | } 853 | } catch (e: Exception) { 854 | println("从QQ音乐API获取歌词失败: ${e.message}") 855 | e.printStackTrace() 856 | } 857 | return null 858 | } 859 | private fun getSongMidFromId(songId: String): String? { 860 | try { 861 | val songDetailUrl = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?tpl=yqq_song_detail&format=jsonp&callback=getOneSongInfoCallback&songid=$songId" 862 | val songDetailResult = getUrlContentWithHeaders(songDetailUrl, mapOf( 863 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", 864 | "Referer" to "https://y.qq.com/" 865 | )) 866 | val jsonStart = songDetailResult.indexOf('{') 867 | val jsonEnd = songDetailResult.lastIndexOf('}') + 1 868 | if (jsonStart >= 0 && jsonEnd > jsonStart) { 869 | val jsonStr = songDetailResult.substring(jsonStart, jsonEnd) 870 | val songDetailJson = JSONObject(jsonStr) 871 | if (songDetailJson.has("data") && !songDetailJson.isNull("data")) { 872 | val data = songDetailJson.getJSONArray("data") 873 | if (data.length() > 0) { 874 | val songInfo = data.getJSONObject(0) 875 | if (songInfo.has("singer") && !songInfo.isNull("singer")) { 876 | val singers = songInfo.getJSONArray("singer") 877 | if (singers.length() > 0) { 878 | val singer = singers.getJSONObject(0) 879 | if (singer.has("mid") && !singer.isNull("mid")) { 880 | return singer.getString("mid") 881 | } 882 | } 883 | } 884 | } 885 | } 886 | } 887 | } catch (e: Exception) { 888 | println("获取歌曲mid失败: ${e.message}") 889 | e.printStackTrace() 890 | } 891 | return null 892 | } 893 | private fun getUrlContentWithHeaders(urlString: String, headers: Map): String { 894 | val url = URL(urlString) 895 | val conn = url.openConnection() as HttpURLConnection 896 | conn.requestMethod = "GET" 897 | conn.connectTimeout = 3000 898 | conn.readTimeout = 3000 899 | headers.forEach { (key, value) -> 900 | conn.setRequestProperty(key, value) 901 | } 902 | return conn.inputStream.bufferedReader().use { it.readText() } 903 | } 904 | } 905 | /** 906 | * 酷狗音乐歌词API 907 | */ 908 | class LyricKugouServlet : HttpServlet() { 909 | private val gson = Gson() 910 | @Throws(IOException::class) 911 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 912 | resp.contentType = "application/json;charset=UTF-8" 913 | val media = PlaybackStateHolder.currentMedia 914 | if (media == null) { 915 | resp.status = HttpServletResponse.SC_NOT_FOUND 916 | resp.writer.write(gson.toJson(mapOf( 917 | "status" to "error", 918 | "message" to "没有当前媒体信息" 919 | ))) 920 | return 921 | } 922 | try { 923 | val lyricContent = getLyricFromKugou(media.title, media.artist) 924 | if (lyricContent != null && lyricContent.isNotBlank()) { 925 | val response = mapOf( 926 | "status" to "success", 927 | "lyric" to lyricContent, 928 | "source" to "kugou" 929 | ) 930 | resp.writer.write(gson.toJson(response)) 931 | } else { 932 | resp.status = HttpServletResponse.SC_NOT_FOUND 933 | resp.writer.write(gson.toJson(mapOf( 934 | "status" to "error", 935 | "message" to "未找到酷狗音乐歌词" 936 | ))) 937 | } 938 | } catch (e: Exception) { 939 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 940 | resp.writer.write(gson.toJson(mapOf( 941 | "status" to "error", 942 | "message" to "获取酷狗音乐歌词失败: ${e.message}" 943 | ))) 944 | } 945 | } 946 | private fun getLyricFromKugou(title: String?, artist: String?): String? { 947 | if (title.isNullOrBlank()) return null 948 | try { 949 | val searchQuery = if (!artist.isNullOrBlank()) "$title $artist" else title 950 | val encodedQuery = URLEncoder.encode(searchQuery, "UTF-8") 951 | val searchUrl = "http://ioscdn.kugou.com/api/v3/search/song?page=1&pagesize=1&version=7910&keyword=$encodedQuery" 952 | val searchResult = getUrlContentWithHeaders(searchUrl, mapOf( 953 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" 954 | )) 955 | val searchJson = JSONObject(searchResult) 956 | if (!searchJson.has("data") || searchJson.isNull("data") || 957 | !searchJson.getJSONObject("data").has("info") || 958 | searchJson.getJSONObject("data").isNull("info") || 959 | searchJson.getJSONObject("data").getJSONArray("info").length() == 0) { 960 | return null 961 | } 962 | val songList = searchJson.getJSONObject("data").getJSONArray("info") 963 | if (songList.length() > 0) { 964 | val songInfo = songList.getJSONObject(0) 965 | if (songInfo.has("hash") && !songInfo.isNull("hash")) { 966 | val hash = songInfo.getString("hash") 967 | val lyricInfo = getLyricInfoFromHash(hash) 968 | if (lyricInfo != null) { 969 | val id = lyricInfo.first 970 | val accesskey = lyricInfo.second 971 | return getLyricFromKugouWithIdAndKey(id, accesskey) 972 | } 973 | } 974 | } 975 | } catch (e: Exception) { 976 | println("从酷狗音乐API获取歌词失败: ${e.message}") 977 | e.printStackTrace() 978 | } 979 | return null 980 | } 981 | private fun getLyricInfoFromHash(hash: String): Pair? { 982 | try { 983 | val lyricInfoUrl = "http://krcs.kugou.com/search?ver=1&man=yes&client=mobi&keyword=%20-%20&duration=139039&hash=$hash" 984 | val lyricInfoResult = getUrlContentWithHeaders(lyricInfoUrl, mapOf( 985 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" 986 | )) 987 | val lyricInfoJson = JSONObject(lyricInfoResult) 988 | if (lyricInfoJson.has("candidates") && !lyricInfoJson.isNull("candidates") && 989 | lyricInfoJson.getJSONArray("candidates").length() > 0) { 990 | val candidate = lyricInfoJson.getJSONArray("candidates").getJSONObject(0) 991 | if (candidate.has("id") && !candidate.isNull("id") && 992 | candidate.has("accesskey") && !candidate.isNull("accesskey")) { 993 | val id = candidate.getString("id") 994 | val accesskey = candidate.getString("accesskey") 995 | return Pair(id, accesskey) 996 | } 997 | } 998 | } catch (e: Exception) { 999 | println("获取歌词信息失败: ${e.message}") 1000 | e.printStackTrace() 1001 | } 1002 | return null 1003 | } 1004 | private fun getLyricFromKugouWithIdAndKey(id: String, accesskey: String): String? { 1005 | try { 1006 | val lyricUrl = "http://lyrics.kugou.com/download?ver=1&client=pc&fmt=lrc&charset=utf8&id=$id&accesskey=$accesskey" 1007 | val lyricResult = getUrlContentWithHeaders(lyricUrl, mapOf( 1008 | "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" 1009 | )) 1010 | val lyricJson = JSONObject(lyricResult) 1011 | if (lyricJson.has("content") && !lyricJson.isNull("content")) { 1012 | val base64Content = lyricJson.getString("content") 1013 | return String(Base64.getDecoder().decode(base64Content), Charsets.UTF_8) 1014 | } 1015 | } catch (e: Exception) { 1016 | println("获取歌词内容失败: ${e.message}") 1017 | e.printStackTrace() 1018 | } 1019 | return null 1020 | } 1021 | private fun getUrlContentWithHeaders(urlString: String, headers: Map): String { 1022 | val url = URL(urlString) 1023 | val conn = url.openConnection() as HttpURLConnection 1024 | conn.requestMethod = "GET" 1025 | conn.connectTimeout = 3000 1026 | conn.readTimeout = 3000 1027 | headers.forEach { (key, value) -> 1028 | conn.setRequestProperty(key, value) 1029 | } 1030 | return conn.inputStream.bufferedReader().use { it.readText() } 1031 | } 1032 | } 1033 | /** 1034 | * 封面图片API 1035 | */ 1036 | class PicServlet : HttpServlet() { 1037 | @Throws(IOException::class) 1038 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 1039 | val coverUrl = PlaybackStateHolder.coverUrl 1040 | if (coverUrl == null) { 1041 | resp.status = HttpServletResponse.SC_NOT_FOUND 1042 | resp.writer.write("封面地址未找到") 1043 | return 1044 | } 1045 | try { 1046 | val url = URL(coverUrl) 1047 | val conn = url.openConnection() as HttpURLConnection 1048 | conn.requestMethod = "GET" 1049 | conn.connectTimeout = 5000 1050 | conn.readTimeout = 5000 1051 | val contentType = conn.contentType ?: "image/jpeg" 1052 | resp.contentType = contentType 1053 | conn.inputStream.copyTo(resp.outputStream) 1054 | } catch (e: Exception) { 1055 | resp.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR 1056 | resp.writer.write("获取封面失败: ${e.message}") 1057 | } 1058 | } 1059 | } 1060 | /** 1061 | * 当前播放位置API 1062 | */ 1063 | class CurrentPositionServlet : HttpServlet() { 1064 | @Throws(IOException::class) 1065 | override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { 1066 | resp.contentType = "application/json;charset=UTF-8" 1067 | val position = PlaybackStateHolder.currentPosition 1068 | val formatted = formatPosition(position) 1069 | val response = mapOf( 1070 | "status" to "success", 1071 | "position" to position, 1072 | "formatted" to formatted 1073 | ) 1074 | resp.writer.write(Gson().toJson(response)) 1075 | } 1076 | private fun formatPosition(position: Long): String { 1077 | val totalSeconds = position / 1000 1078 | val minutes = totalSeconds / 60 1079 | val seconds = totalSeconds % 60 1080 | val millis = position % 1000 1081 | return String.format("%02d:%02d:%03d", minutes, seconds, millis) 1082 | } 1083 | } 1084 | /** 1085 | * 发送系统媒体键事件 1086 | */ 1087 | fun sendMediaKeyEvent(virtualKeyCode: Int) { 1088 | try { 1089 | val user32 = User32Ex.INSTANCE 1090 | user32.keybd_event(virtualKeyCode.toByte(), 0, 0, 0) 1091 | Thread.sleep(10) 1092 | user32.keybd_event(virtualKeyCode.toByte(), 0, 2, 0) 1093 | } catch (e: Exception) { 1094 | println("发送媒体键事件失败: ${e.message}") 1095 | } 1096 | } 1097 | interface User32Ex : com.sun.jna.Library { 1098 | fun keybd_event(bVk: Byte, bScan: Byte, dwFlags: Int, dwExtraInfo: Int) 1099 | companion object { 1100 | val INSTANCE: User32Ex by lazy { 1101 | Native.load("user32", User32Ex::class.java) as User32Ex 1102 | } 1103 | } 1104 | } 1105 | } 1106 | -------------------------------------------------------------------------------- /src/main/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zmxlsss666/SaltLyricPlugin/996983922099744b8d17f61905ddebb8c1c3715b/src/main/resources/icon.png -------------------------------------------------------------------------------- /src/main/resources/preference_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "configs": [ 3 | { 4 | "title": "桌面歌词设置", 5 | "config": "desktop_lyrics_config.json", 6 | "preferences": [ 7 | { 8 | "type": "button", 9 | "title": "打开完整设置", 10 | "summary": "打开完整设置", 11 | "arrow_type": "link", 12 | "on_click": "com.zmxl.plugin.lyrics.DesktopLyrics.openSettingsDialog" 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/main/resources/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SaltPlayer控制器 7 | 8 | 424 | 425 | 426 |
427 |
428 |
429 |
430 | 431 | Album Cover 432 |
433 | 434 |
435 |

正在加载中...

436 |

SaltPlayer

437 | 438 |
439 |
440 |
441 |
442 |
443 | 00:00 444 | 00:00 445 |
446 |
447 | 448 |
449 | 450 |
451 | 454 | 457 | 460 | 461 |
462 | 465 | 466 |
467 |
468 |
469 |
470 | 471 |
472 |
将自动滚动到当前歌词
473 |
474 | 歌词 475 |
476 |
477 |

加载歌词中...

478 |
479 |
480 | 481 |
482 |
483 | 484 | 485 | 486 | SaltPlayer API 连接中... 487 | 488 |
489 |
490 | 491 | 最后更新: --:--:-- 492 |
493 |
494 |
495 | 496 |
497 |
498 | 499 | 940 | 941 | 942 | --------------------------------------------------------------------------------