Hexo Butterfly 集成 Mineradio 粒子音乐播放器

本文记录如何把 Mineradio 的粒子视觉和播放器界面移植到 Hexo Butterfly,并改造成一个不依赖自建音乐服务端的独立静态页面。

最终效果可以在本站的八音盒中查看:进入页面后直接显示 Mineradio,左侧为固定网易云歌单,右上角“视觉设置”可以切换粒子预设和调整视觉参数。

一、最终方案

这次没有把播放器嵌入普通文章页,而是把它放在一个独立目录中:

1
2
3
4
5
6
7
8
9
source/relaxation/music/
├── index.html
├── playlist-cache.json
├── LICENSE
├── NOTICE.md
├── assets/
├── css/
├── js/
└── vendor/

访问 /relaxation/music/ 时,浏览器直接打开这个静态页面,不经过 Butterfly 的文章布局。这样可以避免主题导航栏、侧边栏、文章容器和播放器的全屏视觉互相挤压。

整个页面只保留以下能力:

功能 实现方式
播放列表 从指定网易云歌单读取,同时保留本地快照兜底
歌曲搜索 只搜索当前已经载入的歌单,不调用搜索服务
音频、封面、歌词 使用歌单接口返回的静态资源地址
粒子动画 沿用 Mineradio 的 Three.js 视觉模块
视觉设置 保留粒子预设、颜色、歌词和动态参数
页面数据 使用浏览器内存和 localStorage,不写入服务端

这里的“纯前端”是指不需要自己部署 Node.js、NeteaseCloudMusicApi 或数据库。页面仍然需要访问网易云相关资源和 Meting 歌单接口,因此离线状态、接口跨域策略或第三方服务变化都会影响播放。

二、准备工作

建议先准备好以下环境:

  • Node.js 18 或更高版本
  • 一个可以正常生成的 Hexo 博客
  • Butterfly 主题
  • 一个公开可访问的网易云歌单

在博客根目录安装依赖:

1
npm install

不需要全局安装 Hexo。本文后续统一使用项目内的 npm scripts:

1
2
npm run server
npm run build

Mineradio 使用 GPL-3.0 许可证。分发修改后的页面时,请保留 LICENSENOTICE.md、原项目署名和对应源码,并自行确认自己的发布方式符合许可证与音乐平台规则。

三、复制播放器静态目录

为了保证 Shader、粒子预设、歌词渲染和播放器状态模块的加载顺序一致,最稳妥的方式是复制完整的静态目录,而不是只摘取某一个 Three.js 文件。

示例成品目录位于:

1405720461/02 - source/relaxation/music

1
2
3
4
5
6
git clone --depth 1 https://github.com/1405720461/02.git mineradio-hexo-example

Copy-Item `
-Path "mineradio-hexo-example/source/relaxation/music" `
-Destination "你的博客/source/relaxation/music" `
-Recurse
1
2
3
4
5
git clone --depth 1 https://github.com/1405720461/02.git mineradio-hexo-example

cp -R \
mineradio-hexo-example/source/relaxation/music \
你的博客/source/relaxation/music

目录中的几个关键文件分别负责:

文件 用途
index.html Mineradio 完整页面结构及资源加载入口
css/index.css 原播放器视觉样式
css/static-blog.css 博客静态版裁剪和响应式适配
js/index-loader.js 按原有顺序加载播放器、歌词和粒子模块
js/static-adapter.js 在浏览器内兼容原项目的 /api/* 调用
js/static-bootstrap.js 读取歌单、建立固定播放队列并初始化页面
playlist-cache.json 远程歌单请求失败时使用的本地快照
vendor/ Three.js、GSAP、music-tempo 等浏览器依赖

四、让 Hexo 原样复制播放器

Hexo 默认会处理 source 下的页面。Mineradio 已经是完整 HTML,不应该再次套入 Markdown 渲染器或 Butterfly 模板,所以要在博客根目录的 _config.yml 中增加 skip_render

1
2
skip_render:
- "relaxation/music/**"

如果原来已经配置了多个目录,可以继续放在数组中:

1
2
3
4
5
skip_render:
[
"box/collect/**",
"relaxation/music/**",
]

生成后应当存在 public/relaxation/music/index.html。如果只有一个 Butterfly 文章页面,通常说明 skip_render 的路径或缩进写错了。

五、添加 Butterfly 菜单入口

打开 _config.butterfly.yml,在 menu 中增加八音盒入口:

1
2
3
4
menu:
首页: / || icon-home || faa-tada
休闲 || icon-pinweishenghuo || faa-tada || hide:
八音盒: /relaxation/music/ || icon-yinle || faa-tada

这个链接会离开 Butterfly 的普通内容页,直接进入 Mineradio 全屏页面。播放器左上角的返回按钮再回到博客首页。

不建议把整个播放器放进文章正文或 iframe,主要原因有三个:

  1. Butterfly 的文章宽度会压缩 3D 舞台和左右面板。
  2. iframe 会增加音频自动播放、全屏和移动端手势的限制。
  3. 主题的全局播放器、歌词栏和 Mineradio 会同时占用底部空间。

六、绑定自己的网易云歌单

网易云歌单地址通常类似:

1
https://music.163.com/#/playlist?id=2733900165

其中 2733900165 就是歌单 ID。打开 js/static-bootstrap.js,修改以下两项:

1
2
var PLAYLIST_ID = '2733900165';
var METING_ENDPOINT = 'https://api.i-meto.com/meting/api';

页面会请求:

1
https://api.i-meto.com/meting/api?server=netease&type=playlist&id=你的歌单ID

请求返回的歌曲会被统一整理为 Mineradio 需要的数据结构:

1
2
3
4
5
6
7
8
9
10
{
id: '歌曲 ID',
name: '歌曲名',
artist: '歌手',
album: '专辑',
cover: '封面地址',
url: '音频地址',
lrc: '歌词地址',
provider: 'netease'
}

还要同步修改 js/static-adapter.js 中返回的歌单信息,避免界面中仍然显示旧 ID:

1
2
3
4
5
playlist: {
id: '2733900165',
name: '我的歌单',
trackCount: tracks.length
}

生成本地歌单快照

playlist-cache.json 可以让页面先显示上一次成功获取的歌单,再在后台尝试刷新远程数据。更换歌单后,建议重新生成快照。

1
2
3
4
5
6
$playlistId = "2733900165"
$url = "https://api.i-meto.com/meting/api?server=netease&type=playlist&id=$playlistId"

Invoke-WebRequest `
-Uri $url `
-OutFile "source/relaxation/music/playlist-cache.json"
1
2
3
4
5
PLAYLIST_ID="2733900165"

curl -L \
"https://api.i-meto.com/meting/api?server=netease&type=playlist&id=${PLAYLIST_ID}" \
-o source/relaxation/music/playlist-cache.json

七、用前端适配层替代服务端 API

原版 Mineradio 的多个模块会访问 /api/song/url/api/lyric/api/search 等接口。直接把页面复制到 Hexo 后,这些地址会返回 404,这也是搜索列表经常显示“搜索失败”的原因。

这里没有重写全部播放器模块,而是在 static-adapter.js 中保存原生 fetch,再拦截同域名的 /api/* 请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var nativeFetch = window.fetch.bind(window);

window.fetch = function mineradioStaticFetch(input, init) {
var url = typeof input === 'string' ? input : input && input.url || '';
var resolved = new URL(url, window.location.href);

if (
resolved.origin === window.location.origin &&
resolved.pathname.indexOf('/api/') === 0
) {
return handleApiRequest(resolved.href);
}

return nativeFetch(input, init);
};

这样原播放器仍然以为自己拿到了 API 响应,实际数据全部来自浏览器中已经载入的 tracks

本地歌单搜索

搜索功能只过滤固定歌单,不请求网易云搜索服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
var query = String(
requestUrl.searchParams.get('keywords') || ''
).trim().toLocaleLowerCase();

var matched = tracks.filter(function (track) {
var text = [track.name, track.artist, track.album]
.map(function (value) {
return String(value || '').toLocaleLowerCase();
})
.join(' ');

return text.indexOf(query) >= 0;
});

点击搜索结果时,只切换到该歌曲播放,原歌单和播放队列的顺序保持不变。

歌词请求

如果歌单数据中已经有歌词,就直接使用;否则读取歌曲的 lrc 地址,并在内存中缓存结果:

1
2
3
4
5
6
var response = await nativeFetch(track.lrc, {
mode: 'cors',
cache: 'force-cache'
});

var lyric = response.ok ? await response.text() : '';

八、裁剪不适合纯前端的功能

原项目包含登录、评论、每日推荐、多平台账户、桌面端热键和本地文件等能力。静态博客版没有配套服务端时,应当关闭这些入口,避免用户点击后得到失败提示。

css/static-blog.css 中可以统一隐藏:

1
2
3
4
5
6
7
8
9
#login-btn,
#user-area,
#update-entry,
#song-comments,
#home-platform-recommend-modal,
#trial-login-btn,
.detail-comment-compose {
display: none !important;
}

适配层还会为空的推荐、登录状态和上报接口返回可识别的静态响应,避免原模块持续报错:

1
2
3
4
5
6
7
8
9
10
11
12
if (path === '/api/listen/report') {
return jsonResponse({ code: 200, ok: true });
}

if (/playlists|recommendations|podcast|feed|daily/.test(path)) {
return jsonResponse({
code: 200,
songs: [],
playlists: [],
data: []
});
}

九、保留粒子控制面板

粒子预设和视觉参数仍然使用 Mineradio 原来的模块。页面右上角保留一个有文字的入口:

1
2
3
4
5
6
7
8
9
<button
id="fx-fab"
type="button"
onclick="toggleFxPanel()"
aria-controls="fx-panel"
aria-expanded="false"
>
<span class="fx-fab-label">视觉设置</span>
</button>

控制面板可以调整粒子预设、主色、歌词、镜头、动态和性能参数。配置保存在浏览器 localStorage 中,因此不需要数据库,但清除站点数据后会恢复默认值。

移动端还要注意:

  • 歌单和视觉设置入口分别放在搜索框下方两侧。
  • 打开面板后隐藏入口,避免按钮压在面板内容上。
  • 面板底部要避开播放器和 safe-area-inset-bottom
  • 所有图标按钮使用固定宽高,避免 SVG 看起来偏移。

十、移除 Butterfly 原有音乐播放器

如果博客之前启用了 APlayer 或 MetingJS,建议关闭全局注入,否则进入其他页面时仍会看到旧播放器和滚动歌词。

项目根目录 _config.yml

1
2
3
aplayer:
meting: true
asset_inject: false

Butterfly 配置中的播放器注入也需要按自己的主题版本关闭,或者至少排除八音盒路径:

1
2
3
4
5
6
7
aplayerInject:
enable: true
per_page: true

pjax:
exclude:
- /relaxation/music/

如果你使用的是自定义底部播放器,还需要从 _config.butterfly.ymlinject.bottom 中移除对应脚本,并检查自定义 CSS 或主题布局文件中是否仍有歌词容器。

十一、本地验证与构建

先生成站点:

1
npm run build -- --force

再启动本地服务:

1
npm run server

打开:

1
http://localhost:4000/relaxation/music/

建议至少检查以下内容:

  • 页面进入后没有 Butterfly 文章容器和启动遮罩。
  • 歌单数量和 playlist-cache.json 一致。
  • 点击歌单和搜索结果都能切歌,队列顺序不变。
  • “视觉设置”可以打开和关闭,粒子预设可以切换。
  • 桌面端没有横向滚动条。
  • 390 × 844 等移动端尺寸下,歌单可以展开、收起和重新打开。
  • 浏览器控制台没有未处理异常和 /api/* 404。
常见问题排查

页面显示 404

检查 _config.ymlskip_render 是否为 relaxation/music/**,并确认 public/relaxation/music/index.html 已生成。

歌单载入失败

先直接访问 Meting 请求地址,确认返回的是 JSON 数组;再检查歌单是否公开、歌单 ID 是否正确,以及 playlist-cache.json 是否是有效 JSON。

搜索始终失败

打开浏览器网络面板。如果 /api/search 返回 404,说明 static-adapter.js 没有在原播放器模块之前加载。它应当放在 <head> 中,并早于 js/index-loader.js

有封面但无法播放

音频地址可能已经过期、歌曲需要会员、资源只提供试听,或当前网络无法访问对应域名。刷新页面可以重新获取歌单,但纯前端方案不能绕过平台版权和会员限制。

移动端不能自动播放

这是浏览器的自动播放策略。让用户主动点击一次播放按钮后再创建或恢复 AudioContext,不要尝试强制绕过。

十二、这个方案的边界

纯前端版本适合个人博客展示和播放公开歌单,但它不是网易云官方播放器,也不能替代完整的音乐服务端。

尤其需要注意:

  1. VIP 或版权受限歌曲是否只能试听,由音乐平台和实际音频地址决定。
  2. 仅在前端填写网易云账号信息并不能安全获得完整会员能力,也不应把 Cookie 写进公开仓库。
  3. 第三方接口、音频地址、歌词地址和跨域规则可能变化,需要保留本地快照和失败提示。
  4. 歌曲、封面和歌词的使用应遵守对应平台协议与版权规则。

如果以后确实需要账号授权、稳定会员音质或私人歌单,就应重新评估服务端代理、凭证保管和访问控制,而不是继续把敏感信息堆在前端。

参考资料