MergeMusicDesktop icon indicating copy to clipboard operation
MergeMusicDesktop copied to clipboard

聚合网易云音乐、QQ音乐、B站的简洁跨平台音乐客户端 | 大二上 Java 结课设计

MergeMusicDesktop

聚合音乐 桌面端

  • 聚合网易云、QQ、B站.... ALL IN ONE !!
  • 美观简洁的 UI 设计
  • 视觉效果佳且具有一定可玩性的音频可视化
  • 桌面跨平台支持(已测试Windows、Ubuntu)

软件截图

概览

如何使用?

  • 对于小白 Windows 用户:下载.zip包,解压到合适的位置,然后双击MergeMusicDesktop.exe运行即可。
  • 对于熟悉 Java 环境的 Windows 用户:下载.jar包,双击或使用java命令运行(打包环境为JDK19)。
  • 对于小白 Linux 用户:不存在,小白不会用 Linux 。
  • 对于一般 Linux 用户:下载解压.tar.gz文件,双击或命令行运行bin目录下的文件即可。最好安装一下ffmpeg(如apt install ffmpeg),否则可能音频解码会失败,另外注意一下整个文件夹是否有可执行权限。
  • 对于熟悉 Java 环境的 Linux 用户:同 Windows ,但注意由于 JavaFX 限制,.jar包不通用。
  • 对于 MacOS 用户:我之前用 GitHub Action 打了包但给朋友试了跑不起来。等我有钱了买了 Mac 再说吧....或者你可以v我一台😋
  • 对于非常熟悉 Java 的用户:直接拉源码呗。
  • 对于非常非常非常....熟悉 Java 和 CS 的用户:直接看代码颅内编译。

进阶使用指南 | 可能遇到的问题

  • 音频可视化不同步:请在设置面板中调节“音频可视化延迟时间”参数。

  • 如何访问会员歌曲或私有歌单:访问 网易云音乐官网QQ音乐官网B站官网,登录,然后F12获取Cookie,具体教程网上有很多(比如这个)。获取Cookie后填到设置面板中,保存即生效。

  • 配置文件和缓存:使用设置中的“二向箔”功能可以全部清理。也可以手动到app文件夹(或.jar包同目录下)找到music.sqlite(音乐、收藏夹数据库)、config.properties(设置信息)以及cache文件夹(音乐图片缓存),可以随意删除,但不要随意修改。

  • 关于音频可视化:可以自己探索调节设置中的参数玩玩,带有“采样”字样的需要切换歌曲才会生效。

  • 关于字体:为了良好的显示体验,全局配置了思源宋体并打包了进去(尽管字体文件有≈10MB),如果使用.jar运行,可以自己下载一个SourceHanSerifCN-Medium.otf放在同一目录下。

  • 其他问题:欢迎提 issue o(〃^▽^〃)o

主要技术细节

开发环境为IntelliJ IDEA 2022.3 + JDK19 + Windows10,测试还在Ubuntu 22.04 以及 Ubuntu 18.04 上进行过。

主要基于 JavaFX 进行开发并用 maven 进行包管理。还使用了 MaterialFX 这个 UI 框架(真的挺不错的),另外还有阿里巴巴维护的 JSON 处理库 fastjon 、图标库 ikonli(使用了谷歌的 Material Icons )。 音乐接口部分还写了一点点 JUnit 的单元测试。

打包使用JDK自带的jpackage,并尝试了一下 GitHub Action ,还真挺爽的,写了一个脚本,可以自动拉取代码和字体文件(因为字体文件太大就没有放到仓库里),并一次性完成多平台的打包。

~~报告文档还没写完就是说~~

这个学期(大二上学期,2022秋)选修了金旭亮老师的 Java 语言程序设计,评分的唯一依据就是课程设计(本来还有期末考但因为疫情取消了)。而课程设计的要求是尽可能使用 JavaFX 编写一个桌面应用程序。我并没有想出更好的点子来,便延续了之前做的网页版 MergeMusic ,做了一个桌面端出来。

UI库的选取

JavaFX 自带的UI样式实在是有点过时,让我比较难以接受,于是就想着寻找一些其他的UI库轮子,正好在浏览JavaFX 官方网站时发现上面列出了一些社区轮子,由于我个人对 Google 的 Material Design 比较情有独钟,就看中了一个叫做 MaterialFX 的UI库。

在 GitHub 闲逛时,我又发现了另一个叫 JFoenix 的库,拥有 6k Star,而且之前用过的一个软件 HMCL 就是用这个写的,然而当我尝试使用这个库时,却接连遇到问题。首先是其官网域名已经过期了,查阅文档比较麻烦,然后是在 Scene Builder 中很多组件显示异常……我翻阅仓库 issue ,发现也有很多人遇到类似的问题,其原因就是这个项目主要是针对JDK8开发的,新版本会有很多兼容性问题,最好的解决方法就是降版本....我最后还是选择浅尝辄止。

而 MaterialFX 诞生的其中一个目的就是为了取代老旧的 JFoenix ,虽然现在还在迭代过程中,有一些功能可能还不够稳定,但毕竟我所用到的功能应该也不会太过复杂,就决定是它了。

另外,由于 MaterialFX 的图标支持不是很好,又引入了 Ikonli 以及由谷歌维护的 Material Icons

音频播放方案

由于播放的格式可能为.mp3.m4a.flac等,但是 JavaSE 中的 Sound API 却只提供了基础的.wav支持,因此必须想其他办法。

首先我的想法集中于寻找第三方包,通过格式转换后再进行播放和处理。经过搜寻,有可以转换aac编码(也就是.m4a封装中的编码)的jaadec,也有可以解析.mp3jmp123等,但是能同时实现各种格式转码的包很少,看起来非常强大的有javacv等封装了ffmpeg的包,但是ffmpeg本身并不能跨平台,虽然有一些取巧的解决方案,但是都会使得软件变得比较臃肿,得不偿失。

最后可谓是「众里寻他千百度,蓦然回首」,发现 Java FX 还有一个叫做 javafx-media的模块,几乎和浏览器中的Web Audio API别无二致(甚至我怀疑他们共享了同样的底层实现),查阅文档发现可以满足需求(除了无法播放.flac,不过无损音乐的功能本来就不是特别常用,也可以通过再加一个包来解决),而且还可以直接使用链接播放,甚至还自带了频谱(都不用自己想办法做傅里叶分解了)!

音乐API部分

网易云音乐

由于网易云音乐所有接口都有AES加密处理,时间有限,暂时懒得研究了。而且现在已经有了非常成熟易用的项目 NeteaseCloudMusicApi ,可以一键部署在普通服务器以及 Vercel 、 Serverless 函数计算等托管平台上,所以就不重复造轮子了。具体接口文档见项目主页。

QQ音乐

参考项目:QQMusicApi

搜索

POST https://u.y.qq.com/cgi-bin/musicu.fcg

body为一个JSON格式的字符串,格式如下:

{
    "music.search.SearchCgiService": {
        "method": "DoSearchForQQMusicDesktop",
        "module": "music.search.SearchCgiService",
        "param": {
            "num_per_page": 24, //每页条目数
            "page_num": 1, //页码 从1开始
            "query": "夜航星", //关键词 无需编码
            "search_type": 0 //0为歌曲 2为专辑 3为歌单 7为歌词 8为用户
        }
    }
}

获取歌曲详情

POST https://u.y.qq.com/cgi-bin/musicu.fcg

body为一个JSON格式的字符串,格式如下:

{
  "songinfo": {
    "method": "get_song_detail_yqq",
    "module": "music.pf_song_detail_svr",
    "param": {
      "song_mid": "004QtMmf2AeGHZ" //填入歌曲mid
    }
  }
}

获取歌词

POST https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg

body格式为format=json&nobase64=1&g_tk=5381&songmid=004QtMmf2AeGHZ,注意该接口要求添加HeadersReferer:y.qq.com

获取歌曲播放链接

POST https://u.y.qq.com/cgi-bin/musicu.fcg

body为一个JSON格式的字符串,格式如下:

{
  "req_0": {
    "module": "vkey.GetVkeyServer",
    "method": "CgiGetVkey",
    "param": {
      "filename":["M500002I2lcO3lqZFW.mp3"], //不传则默认为m4a格式
      "guid": "2333",
      "songmid": [
        "004QtMmf2AeGHZ" //填入mid
      ],
      "songtype": [
        0
      ],
      "loginflag": 1,
      "platform": "20"
    }
  },
  "comm": {
    "format": "json",
    "ct": 24,
    "cv": 0
  }
}

其中filename的计算需要用到media_mid,在歌曲详细信息接口中可以获取到。

获取歌单详情

POST http://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg

body格式为type=1&utf8=1&format=json&disstid=7479057129,需要Referer

获取用户歌单

POST https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss

body格式为size=2333&inCharset=utf8&outCharset=utf8&hostuin=1234567890

获取专辑歌曲

POST https://u.y.qq.com/cgi-bin/musicu.fcg

body为一个JSON格式的字符串,格式如下:

{
  "albumSonglist":{
    "method":"GetAlbumSongList",
    "param":{
      "albumMid":"003lkdBY4bs97f" //填入专辑mid
    },
    "module":"music.musichallAlbum.AlbumSongList"
  }
}

bilibili

搜索

GET https://api.bilibili.com/x/web-interface/search/type

参数说明:

  • search_type: video | bili_user
  • page: 页码从1开始
  • page_size: 每页数量
  • keyword: 关键词,需要URLEncode

需要Cookie,经实验buvid3=xxx都可以。

另外注意搜索结果标题关键字会被<em class="keyword"></em>包裹、&会被转义成&amp;等情况需要特殊处理。

获取视频详情

GET https://api.bilibili.com/x/web-interface/view?aid=

获取播放源

GET https://api.bilibili.com/x/player/playurl?fnval=80&avid=73751088&cid=126162431

获取UP作品列表

GET https://api.bilibili.com/x/space/arc/search?ps=50&mid=uid&pn=1

注意这个接口如果要传Referer就只能传https://space.bilibili.com,否则会被拦截。

获取用户收藏列表

GET https://api.bilibili.com/x/v3/fav/folder/created/list?pn=1&ps=100&up_mid=

获取收藏夹内容

GET https://api.bilibili.com/x/v3/fav/resource/list?ps=20&pn=1&media_id=

数据库

使用sqlite-jdbc操作SQLite数据库。

开始设计时,考虑到列表和歌曲是一个多对多的关系,所以就想到先对歌曲和列表分别建立一张表,然后再通过一张中间表记录对应的list_idmusic_id来将两张表关联起来。但是后来发现每次对于列表的操作都是整读整取,不存在读部分列表的情况,所以就没有必要建立三张表了,直接把一个列表里的歌曲列成一个字符串,然后存到一个字段里就行。

打包与分发

开始时尝试了 IntelliJ 的 Artifacts 导出 JavaFX Application 的方案以及其他的一些 maven 插件,但是都遇到了各种各样的报错,最终发现JDK中的jpackage一行命令就能搞定....

可以参考文档 Packaging Tool User's GuideThe jpackage Command

果然还是应该先把官方的东西吃透。感觉 Java 生态和之前比较了解的前端生态的一大不同之处就是 Java 的生态还是官方主导的,大部分会用到的东西官方都已经做好了,反而是社区的东西很多情况下不如官方的好使。这点前端生态就跟更加去中心化一些。

整个流程为:

  1. 克隆项目
  2. 加载pom.xml中指定的 maven 依赖
  3. 执行mvn clean package
  4. 删除target目录中除了打包完整的.jar之外的所有文件(否则会被打包进去)
  5. 在项目根目录下运行如下命令(可根据需求修改)
jpackage --name MergeMusicDesktop --input ./target/ --main-jar ./MergeMusicDesktop-1.0-SNAPSHOT.jar --type app-image --icon ./other/icon.ico --resource-dir ./other/ --app-version 0.0.0 --copyright "Copyright flwfdd All Rights Reserved" --description MergeMusicDesktop-聚合音乐桌面端 --java-options -Xmx256m

问题

TableView 数据不同步

进行MFXTableView<T>.setItems(ObservableList<T> items)操作时,如果items初始时是一个空列表,那么后面的更改就无法被同步过去,原因不明。

解决方案为执行此命令前向items中添加一个null,执行完绑定后立刻clear,之后再更改时即可同步。

TableView 滚动错位

设置单元格鼠标悬浮提示时,有这样的代码:

colName.setRowCellFactory(music->{
    var rowCell=new MFXTableRowCell<>(Music::getName);
    new MFXTooltip(rowCell,music.getName()).install();
    return rowCell;
});

但是测试时发现,在表格滚动后,悬浮的提示内容不会随着滚动更改,也就是这里的music变量其实并不是每一行真正的music,真正的是通过传给MFXTableRowCell的那个函数动态设置的。

最后通过把悬浮提示文字绑定到单元格文字上解决。

void createTooltip(MFXTableRowCell<Music,String> rowCell){
    var tooltip=new MFXTooltip(rowCell);
    tooltip.textProperty().bind(rowCell.textProperty());
    tooltip.install();
}

按键事件鬼畜

实现空格切换播放暂停、方向键调节音量和播放进度等功能后,测试时发现空格偶尔会触发切换歌曲的操作,感到非常不解。后来发现是如果先点击了下一首按钮,再按空格就会触发下一首按钮的事件,应该是当焦点在按钮上时,按下空格就相当于按下按钮。

开始时空格事件是通过在根Scene上添加addEventHandler实现的,改成了addEventFilter并且立即consume后,事件就不会再派发下去了,解决了这个问题。

然而另一个关于输入框的问题又浮现出来。尽管通过实验发现输入框的文字输入以及setOnKeyPressed(用于监听回车搜索)和addEvent的事件并不在同一套系统里,即使在上层消费掉了事件,也不会影响输入框的输入。但是当输入框输入空格时,同样会触发播放暂停事件(从操作逻辑上这显然是不合理的),而且这个问题无法通过在输入框中消费事件解决,因为父级会先捕获到事件。

这个问题的解决方案是在捕获事件时对事件的target进行一系列的判断,如果是输入框则不执行操作。

万万没想到,又一个问题出现了:如果焦点在一个按钮上时按下空格执行了播放暂停的操作,那么之后这个按钮就会对鼠标点击无响应,需要到焦点移开时才能够继续响应点击事件。猜测是onAction本来是对空格事件有相应的,但是事件被消费造成某个环节卡住了。

解决方法堪称奇葩——在consume事件的同时,让一个无关紧要的组件(实际代码中使用了展示背景图片的backgroundPane)来requestFocus以把焦点移走,这样焦点不在原来的按钮上,自然也就没有问题了。

条件 Bingding 没有更新

实现音量控制模块时,想要做一个一键静音功能,而最终的音量在没有静音时与音量条一致,静音时则设置为0。开始时我这样写:

realVolume=new When(mute).then(showVolume).otherwise(0);

却发现静音按钮可以使用,但是当拖动进度条也就是改变showVolume时,realVolume并没有更新,看来是只会绑定到条件语句上。最后更换为这样的写法就可以了:

realVolume=showVolume.multiply(new When(mute).then(0).otherwise(1));

Image 类异步加载

发现点击播放歌曲时会稍稍卡一下,但是获取歌曲的部分已经做了异步了,后来发现是因为执行new Image(url)初始化时不是异步的,造成了几百毫秒的阻塞,将图片加载部分扔到另一个线程里就行了。

内存占用分析

偶然打开任务管理器发现竟然占了1G+的内存,于是尝试使用 Java 监视与管理控制台(JConsole)进行了监控,发现内存占用一直在锯齿状大波动。查资料又发现了一个叫做 JProfiler 的工具,可以进行比较详细的 Java 运行监控。发现是在加载歌曲时会有一个很大的内存暴涨,导致 JVM 申请了很大的内存,之后又一直都在反复占用,但是手动进行垃圾回收后内存占用会降低到100+MB,并且可以稳定正常运行。后来尝试加上了运行参数-Xmx256m,仍然可以正常运行, JProfiler 里也能看到内存占用一直没有超出过256MB,但是任务管理器中内存占用仍然会到800+MB,我感到非常不解。

经过测试发现当播放音乐时内存就会暴涨,进一步调试发现真正造成这个现象的不是播放音乐,而是加载歌曲图片。加载一个10MB左右的图片就会使得内存暴涨几百兆,这应该是 JavaFX 中Image类实现的问题,其内部没有用压缩方式存储图片,且内存释放逻辑也有问题。我没能想到怎么从正面角度解决这个问题,只能减小图片尺寸,避免使用Image加载大尺寸图片。

URL 编码

开始使用了现成的OkHttp库,后来发现并没有太大必要,想换回URLConnection时,发生了错误。

java.io.IOException: Server returned HTTP response code: 400 for URL

原因就是URLConnection并不会对链接进行自动转义,如果链接中包含了中文或空格等字符就会出错,需要手动使用URLEncoder.encode进行转义。但后来又发现如果把整个url都进行转义,那么包括http://中的符号等也会一并转义,还是不行,所以就只能对其中可能包含非法字符的部分进行转义。

数据库多线程冲突

当多个线程同时调用数据库时,会出现错误:

org.sqlite.SQLiteException: [SQLITE_BUSY] The database file is locked (database is locked)

解决方法是把获取数据库连接的代码单独为一个函数,并且用synchronized关键字修饰,这样当有冲突时后面的就会等待前面的连接释放。

synchronized Connection getConnection() throws SQLException {
        return DriverManager.getConnection(dbURL);
    }

数据库REPLACE INTO语句问题

本来使用这个语句来进行更新条目,如果不存在就新建。后来发现这条语句的更新并非是真正更新,而是替换。举例来说,我有一个src字段设置了默认值,当执行REPLACE INTO命令时,虽然没有更新src字段,但是这个字段并没有保持原来的值而是变成了默认值。

解决方法是改成了INSERT INTO .... ON CONFLICT DO UPDATE SET的形式。

打包jar运行报错问题

打包为单个.jar文件运行时出现了错误:

Caused by: java.lang.UnsupportedOperationException: Cannot resolve 'mdrmz-skip_previous'
        at org.kordamp.ikonli.AbstractIkonResolver.resolve(AbstractIkonResolver.java:61)
        at org.kordamp.ikonli.javafx.IkonResolver.resolve(IkonResolver.java:73)
        at org.kordamp.ikonli.javafx.FontIcon.setIconLiteral(FontIcon.java:251)
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
        ... 23 more

看样子是 ikonli 图标库的问题,于是到项目的 issue 下搜索,发现项目官网就提供了解决方法,只需要在 maven 里添加一个插件即可。具体的原理还没有研究,反正是能用了。

在此之后也就不能使用 IntelliJ 的 Artifacts 来进行打包了,而要使用 maven 命令mvn clean package

打包完运行又出现错误提示“没有主清单属性”,还需要在插件配置里加上以下内容来指定主类。

<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
    <mainClass>xyz.flwfdd.mergemusicdesktop.Main</mainClass>
</transformer>

点击关闭后没有完全退出

点击关闭按钮关闭程序后,IntelliJ 中仍然显示程序没有停止,开始还以为是 IntelliJ 卡了,后来使用 JProfiler 监控发现是有一个线程一直没有退出。窗口关闭按钮只会强制退出主线程,如果要完全退出程序,需要加入如下代码:

stage.setOnCloseRequest(new EventHandler<WindowEvent>() {
    @Override
    public void handle(WindowEvent windowEvent) {
        System.exit(0);
    }
});

这样确实关闭的时候就完全退出了。但是这是治标不治本的办法,之前是没有这个问题的,肯定是加了某些代码后造成了这个问题,所以决定排查出来。然而调试工具并不能指出那个没有退出的线程是由哪里的代码产生的,我把所有new Thread()的地方排查了一遍也没有找到问题所在。最终通过不停回退git版本,发现是在加入了提示消息的时候引入了这个问题,进一步排查发现是为了实现提示消息一定时间自动隐藏的功能,定义了一个对象的全局变量Timer timer = new Timer();,如果把这个Timer改为即用即抛的局部变量就不会出现问题,推测是Timer会保持一个线程,而因为定义在对象的属性里,不会被自动回收,就一直不退出了。