我做了个能同时听网易云和 B 站的桌面播放器(v0.1.0 发布记)

一个 Windows 桌面音乐播放器:把网易云音乐和哔哩哔哩收藏放进同一个播放队列, Material You 取色 + 亚克力材质,尽量接近 Windows 11 原生应用的观感。 第一版 v0.1.0 已经发布,安装包 11 MB。

主界面:发现音乐

为什么要自己写一个

起因很具体:网易云的版权少,同时B站的收藏的音乐一半来说音质不差并且由于是up重制都不会因为版权无法播放, 而这两边的歌没法放进同一个播放列表。官方客户端越来越杂,我只想要一个 "能登录、能放歌、界面不碍眼" 的东西。

所以这个项目从第一天就定了三条线:

  1. 只做我听歌需要的事

  2. 两个音源在同一个队列里,媒体控制、歌词、音质、下载都是一套;

  3. 做不到的事直说,不做一个看起来在工作、其实没接上的开关。

歌单页:可以「播放全部」,也可以「添加到队列」

这一版能做什么

  • 网易云:扫码 / 手机号登录,我的歌单、每日推荐、搜索;音质档位可选, 自动档会挑账号真正拿得到的最高档(而不是嘴上说无损、实际给你 320k)

  • 哔哩哔哩:扫码登录,收藏夹(含合集)作为一个歌单;未登录也能浏览公开收藏夹

  • 同一个队列:两个音源的曲目可以混排顺序播放,互不打断

  • 全屏播放页:Apple Music 风格的歌词

  • 播放队列:右侧滑入,打开时自动聚焦当前正在播放的那首

  • 主题:6 种窗口材质(亚克力 / Mica / Mica Alt / 高斯模糊 / 模拟磨砂 / 实色), 封面取色(Monet 算法)、全局字体可选、窗口不透明度与磨砂强度可调

  • 下载管理:分音源,可改下载目录

  • 账户页:一个地方管所有音源的账号;没做完的音源如实标成「规划中」

  • 内置一个调试日志面板,排查"这首为什么放不了"用


全屏播放页:把歌词做成"聚焦在中间"

全屏播放页:当前行居中,离当前行越远越淡

看起来简单,但有两个地方必须想清楚,否则观感立刻廉价:

一、当前行要真的在正中间。 常见做法是给列表上下加一个固定内边距, 但那样首行和末行永远居中不了 —— 滚动位置会被夹在合法区间里,目标偏移是负数。 正确的关系是:内边距等于 (视口高 − 行高) / 2, 此时"第 i 行居中"的偏移就干净地等于 i × 行高,首末行才够得着。

二、上下渐隐的方向很容易写反。 Flutter 的 ShaderMask 配合 BlendMode.dstIn, 语义是"保留遮罩里不透明的部分"。所以渐变必须是两端透明、中间不透明。 我第一版写成了两端不透明、中间透明 —— 结果是把歌词中间整片擦掉, 界面上只剩顶端一行,而基于 Rect 的布局断言完全看不出这种错(行的位置都对,只是没画出来)。 最后是抽成一个纯函数 + 一条取图数像素的测试才把它钉住。

另外,位置每 200ms 刷新一次,但只有当前行真的变了才滚动, 否则整块歌词会一直在抖。


播放队列:打开就对准当前那首

播放队列

队列可能有几百首。打开时如果从第一首开始显示,用户每次都得自己找"现在放到哪了"。

做法上有个细节:不要"先渲染再从顶部滚过去"(能看到跳动),而是首帧就用 正确的 initialScrollOffset。这条我用测试守住了 —— 断言首帧里没有任何滚动通知: 只要出现 ScrollStart/Update,就说明是靠事后补偿实现的,那一定看得出跳。

还有一条不那么显然的:用户手动滚过之后,换歌不能把他拽回去。 所以他滚到哪就停在哪,滚回当前曲目附近才恢复跟随。


几个较真的地方(也是这次踩坑最多的地方)

1. just_audio 的 play() 是"播放结束才完成的 Future"

这不是文档里显眼的位置,但它决定了很多事的顺序。原代码是:

dart

if (autoPlay) {
  await engine.play();   // ← 这里会一直挂到整首歌放完
  _startFadeIn();        //    所以起播渐强其实从来没在起播时执行过
}
unawaited(_prefetchNext()); // 预解析同理,被推迟到歌曲结束

await 它等于把后面所有事推迟到歌曲播完。改成"发起播放后立刻继续", 把 Future 交给一个只负责收尾(失败要写日志和界面)的辅助方法。

更值得说的是为什么之前没发现:我测试里的假引擎 play() 立刻返回, 没有照抄真实契约,于是这个 bug 在测试里完全隐形。把假引擎改成"到暂停/停止才完成"之后, 那条回归用例在改回旧写法时立刻变红。

2. 字重:一个只有"常规体"的字体,被请求了粗体

内置字体只有一个字面(w400),而主题里到处请求 w500/w600/w700。 Flutter 在"请求的字重不存在"时会描边合成粗体。我量了一下墨迹像素:

请求字重

相对 w400 的墨迹

w500

1.0000(不合成)

w600

1.5124(+51%,在描边)

w700

1.5124

所以界面上"部分字体发虚、粗细不匀"其实只发生在请求 w600/w700 的那些地方 —— 阈值刚好在 w600。

3. 更隐蔽的一层:按钮根本没在用这个字体

改完字重后用户反馈"还是不一样"。把按钮文字解析后的样式从渲染树里读出来,才看到真正的病根:

代码块

「播放全部」   → family=null     ← 不是内置字体
「添加到队列」 → family=null     ← 不是内置字体
正文          → family=Zhuzi

原因是 ThemeData(fontFamily:) 只派生 textTheme,而 ButtonStyle.textStyle / AppBarTheme.titleTextStyle / ChipTheme.labelStyle 这类组件级样式是"整体取代"对应那一档,不是与之合并 —— 只写 fontSize/fontWeight,字体族就丢了,那段文字会掉回系统默认字体。

主题里有 14 处这样的地方,全中。现在统一走一个辅助函数构造, 并有一条测试读解析后的样式(不是读源码文本)来守它。

4. 版权下架的歌:跳过它,但界面必须跟着走

一首歌因为版权下架放不了时,正确的行为是按队列切到下一首, 而不是停在那里报错等人点。这里有两个坑:

  • 切歌前必须先停引擎。否则引擎里还留着上一首的流继续出声, 而界面已经切到这首下架歌上了 —— 就是"声音是上一首、封面和歌词是这一首"的脱节;

  • 跳过必须有次数上限。在"列表循环"模式下,"下一首"永远存在(到头会绕回开头), 整队都是下架歌时没有上限就是无限递归。

判定"放不了"用的是类型化信号(列表层的 playable 或解析层声明的 unplayable), 不是去匹配错误文案。网络失败坚决不切歌 —— 换一首歌并不会让网络恢复, 反而会把用户真正想听的那首跳过去。


打包与分发

安装包用 Inno Setup,几个我觉得值得记下来的决定:

  • 按用户安装(PrivilegesRequired=lowest),不弹 UAC,装到 %LOCALAPPDATA%\Programs。 这样"安装完就能勾开机自启动"是天然成立的 —— 那个写的是 HKCU 的启动项;

  • 安装时可以选三个路径:安装路径、缓存路径、下载路径。 后两个写进 installer.json,应用首次启动时读取并落进自己的设置 (用文件而不是注册表:应用本来就在同一目录存偏好,卸载也不会留注册表垃圾);

  • 完成页上有两个勾选框:「立即运行」和「开机自启动」(走 [Run] + postinstall);

  • 安装包不含那个 121 MB 的 Node 运行时(内嵌的网易云接口服务)。 塞进去会让安装包大三倍,改成首次启动时下载一个合并包(压缩后 47.6 MB), 下完先校验 SHA-256,不匹配就拒绝安装 —— 宁可失败,也不装半个坏的运行时;

  • 内置字体不随安装包分发(再分发许可未核实,按作者自用处理)。 打包脚本会在暂存阶段删掉字体文件,并且从 FontManifest.json 里摘掉对应条目 —— 只删文件是不够的,清单里还列着它的话,引擎会去找一个不存在的资源。

顺手也修了一处文档与代码不符:文档里写着"CMake 在构建时把 runtime/ 复制到 exe 同级", 但 windows/CMakeLists.txt 里并没有这条规则。这类"文档描述了一个不存在的机制" 比没有文档更误导人。


已知限制(不打算藏着)

  • 均衡器:界面、预设、十段推子都在,但当前的 Windows 播放后端 (just_audio_windows / Media Foundation)没有暴露音频效果接口, 曲线不改变声音 —— 界面里也这么标着,不做一个看起来在工作、其实没接上的假均衡器

  • 无缝衔接只做到"预解析下一首、消除空档",不是采样级无缝

  • 哔哩哔哩掉线后只能重新扫码,无法自动续期

  • 强杀进程(任务管理器结束任务)会残留一个 node.exe;正常关闭不会

  • 网易云 SVIP 识别不出来,所以超清母带 / 高清臻音 / Hi-Res 实际拿不到

  • 内置字体不随安装包分发,安装版会用系统字体(与截图观感略有差别)

  • QQ 音乐 / 酷狗 / NAS 只是「账户」页上的占位,目前不可用


下载

在 Releases 页面:

文件

大小

说明

zhuoyue-player-0.1.0-setup.exe

11.2 MB

安装包(不含运行时、不含内置字体)

runtime-v1.zip

47.6 MB

首次启动时自动下载的运行时(Node + 网易云接口服务)

校验和:

代码块

zhuoyue-player-0.1.0-setup.exe
  SHA-256  2874D8E21A8150D0DB099C5E39C5D9B1394C561586E0D24376DEBF2AE9BA1F23

runtime-v1.zip
  SHA-256  01D4BC7FFF01D88C180526B4C47D1380A7973F38A85221A81F6C11D2CA0472FC

首次启动需要联网(准备运行时)。下载失败时应用会给出可操作的原因(可重试), 也可以手动把解压出的 runtime/(含 node/ 与 netease-api/)放到程序所在目录后重启。


免责声明

本项目是非官方第三方客户端,与网易云音乐、哔哩哔哩均无关联。 使用的接口随时可能失效或变更;请通过官方渠道支持创作者与平台, 不要用于商业用途、批量抓取,或用于绕过任何付费与版权限制。


本文提到的所有实测数字(墨迹比值、超时行为、哈希)都来自项目内的测试与脚本, 不是估算。