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

为什么要自己写一个
起因很具体:网易云的版权少,同时B站的收藏的音乐一半来说音质不差并且由于是up重制都不会因为版权无法播放, 而这两边的歌没法放进同一个播放列表。官方客户端越来越杂,我只想要一个 "能登录、能放歌、界面不碍眼" 的东西。
所以这个项目从第一天就定了三条线:
只做我听歌需要的事
两个音源在同一个队列里,媒体控制、歌词、音质、下载都是一套;
做不到的事直说,不做一个看起来在工作、其实没接上的开关。

这一版能做什么
网易云:扫码 / 手机号登录,我的歌单、每日推荐、搜索;音质档位可选, 自动档会挑账号真正拿得到的最高档(而不是嘴上说无损、实际给你 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 在"请求的字重不存在"时会描边合成粗体。我量了一下墨迹像素:
所以界面上"部分字体发虚、粗细不匀"其实只发生在请求 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
SHA-256 2874D8E21A8150D0DB099C5E39C5D9B1394C561586E0D24376DEBF2AE9BA1F23
runtime-v1.zip
SHA-256 01D4BC7FFF01D88C180526B4C47D1380A7973F38A85221A81F6C11D2CA0472FC首次启动需要联网(准备运行时)。下载失败时应用会给出可操作的原因(可重试), 也可以手动把解压出的 runtime/(含 node/ 与 netease-api/)放到程序所在目录后重启。
免责声明
本项目是非官方第三方客户端,与网易云音乐、哔哩哔哩均无关联。 使用的接口随时可能失效或变更;请通过官方渠道支持创作者与平台, 不要用于商业用途、批量抓取,或用于绕过任何付费与版权限制。
本文提到的所有实测数字(墨迹比值、超时行为、哈希)都来自项目内的测试与脚本, 不是估算。
评论交流
欢迎留下你的想法