说在前头
我的博客当时基于名为 Firefly 的 Astro 主题二次开发。原主题上游链接现已失效,相关历史实现与配置可以参考旧版 aria7-blog 仓库。它自带一个挺好看的音乐播放器,支持Meting API在线音乐和本地音乐两种模式,唯独不支持B站
我平时听歌基本在B站,好听的BGM都在视频里,就想能不能直接在博客播放器里放B站视频的音频
于是我开始了漫长的魔改之路…
整体结构
先放最后做出来的样子:
用户输入 BV 号
↓
Vercel Serverless (api/bilibili-audio.js)
├── 请求 B站官方接口获取音频流
│ └── 返回 { title, artist, pic, audio_url }
↓
MusicManager (后台控制器)
├── 管理播放状态、队列、音量
├── 维护已保存的视频收藏
└── 维护自建播放列表
↓
MusicPlayer (UI 组件)
├── 播放器控件(播放/暂停/上下曲/进度条)
├── 播放列表抽屉
├── 歌词展示
└── 音源面板(添加视频、收藏、播放列表)
主要是两个Astro组件:MusicManager管数据,MusicPlayer管界面,两边通过全局对象window.__fireflyMusic和自定义事件fm:*通信
第一步:B站音频代理
浏览器直接请求api.bilibili.com会被CORS拦下来,所以得有个服务端帮忙转一下,我用的是Vercel Serverless Function
api/bilibili-audio.js 接收 ?bvid=BV1xxx&meta=1 参数,向B站接口请求数据:
// Step 1: 获取视频基本信息
const infoUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`;
// Step 2: 获取音频流 URL
const audioUrl = `https://api.bilibili.com/x/player/playurl?bvid=${bvid}&qn=0&fnver=0&fnval=4048&fourk=1`;
// Step 3: 返回格式化数据
return { title, artist, pic, audio_url };
这里关键是fnval=4048,它让B站返回dash格式,音频和视频是分开的两条轨,直接拿audio那条就行,前端一个<audio>标签就能播
Vercel上给这个函数配了512MB内存和30秒超时:
{
"functions": {
"api/bilibili-audio.js": {
"memory": 512,
"maxDuration": 30
}
}
}
第二步:两个组件怎么通信
播放器的状态和逻辑都在MusicManager里,用IIFE包起来挂到window.__fireflyMusic上:
MusicManager → 事件广播 → MusicPlayer
↑ ↓
└── 方法调用 ←───────────┘
事件系统
// MusicManager 端
function emit(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail }));
}
// MusicPlayer 端
function on(name, fn) {
handlers[name] = fn;
window.addEventListener(name, fn);
}
播放器状态
var state = {
playMode: 0, // 0=列表循环, 1=单曲循环, 2=随机
volume: 0.7,
isMuted: false,
isPlaying: false,
playlist: [],
currentIndex: 0,
lyrics: [],
currentLrcIndex: -1
};
状态只放在Manager里,Player只负责听事件、更新界面,要改状态就调Manager的方法。这样改界面的时候基本不用动播放逻辑

第三步:第三方API和官方API轮流试
拿B站音频不止一条路,我让MusicManager按顺序挨个试:
setBilibiliSource('video', bvid)
↓
Layer 1: 第三方 API(用户配置的多个 API 端点)
├── 遍历所有 API
├── 替换 :type, :id, :r 占位符
└── 如果成功 → 使用该数据
↓ (全部失败)
Layer 2: B站官方 API(通过代理)
├── /api/bilibili-audio?bvid=xxx
├── 获取音频直链
└── 如果成功 → 使用该数据
↓ (全部失败)
抛出错误 "All Bilibili APIs failed"
第三方API说挂就挂,有官方接口兜底,至少还能放
第四步:自建播放列表
一开始想的是直接导入B站收藏夹,结果限制一堆:要Cookie、有跨域问题、私密收藏夹根本读不到,做出来很难用

后来干脆不接收藏夹了,在博客里自己建歌单,把想听的BV号存进去:
数据模型
// localStorage["music-custom-playlists"]
[
{
id: "pl_xxx_yyy",
name: "日推歌单",
tracks: [
{ bvid: "BV1Gi576kE2T", title: "使一颗心免于哀伤", artist: "知更鸟", pic: "" },
{ bvid: "BV1xxx", title: "卡农摇滚版", artist: "...", pic: "" }
],
createdAt: 1685000000000,
updatedAt: 1685000000000
}
]
功能
音源面板分三个区域:
1. 视频输入区 —— 输入BV号点击“应用”直接播放,或点“收藏”保存到本地
2. 我的收藏 —— 所有收藏过的视频列在这里。每条右侧有两个按钮:
[+]弹出播放列表选择框,将视频添加到指定歌单[删除]从收藏中移除
3. 自建播放列表 —— 顶部输入框可创建新的歌单。每个歌单条目显示名称和曲目数,hover后出现“全部播放”和“删除”按钮。点击歌单标题可展开查看内部曲目,每首曲目同样有[+](添加到播放队列)和[删除](移出歌单)按钮
每个[+]按钮会弹出播放列表选择框,点击后曲目会追加到主播放队列的末尾,而不是替换当前播放
![已保存的视频收藏列表:每个视频旁有[+]添加到播放列表和删除按钮](/assets/images/posts/B%E7%AB%99%E9%9F%B3%E4%B9%90%E6%92%AD%E6%94%BE%E5%99%A8/saved-videos.webp)
![自建播放列表展开状态:显示曲目名称、BV号,右侧[+]和删除按钮](/assets/images/posts/B%E7%AB%99%E9%9F%B3%E4%B9%90%E6%92%AD%E6%94%BE%E5%99%A8/playlists.webp)
多曲目队列
async function setBilibiliPlaylist(bvids) {
// 逐个获取音频 URL
var tracks = [];
for (var i = 0; i < bvids.length; i++) {
var t = await fetchBilibiliVideoTracks(bvids[i]);
tracks.push(t[0]);
}
state.playlist = tracks; // 替换整个队列
loadTrack(0, true); // 自动播放第一首
}
async function addToBilibiliQueue(bvid) {
var t = await fetchBilibiliVideoTracks(bvid);
state.playlist.push(t[0]); // 追加到队列末尾
emit('fm:queue-updated'); // 通知 UI 刷新
}
这样就能从不同歌单里挑几首拼成一个队列,再配上列表循环、单曲循环、随机三种模式,自己用着还挺顺手
其他细节
1. 抽屉展开动画
列表和音源面板的展开收起用的是grid-template-rows过渡:
.playlist-drawer, .source-drawer {
transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
cubic-bezier是缓动曲线,让开合不那么生硬
之前我以为这样能走GPU,其实不能:grid-template-rows一变就会触发重新布局。抽屉里东西不多所以感觉不到卡,内容多了的话还是得换成transform之类的方案
2. 清理事件监听
页面切换时播放器组件可能被移除,挂在window上的监听不解绑就会泄漏:
var observer = new MutationObserver(function (mutations) {
for (var mutation of mutations) {
if (mutation.removedNodes.contains(widget)) {
Object.keys(handlers).forEach(function (name) {
window.removeEventListener(name, handlers[name]);
});
document.removeEventListener('click', popupDocHandler, true);
observer.disconnect();
}
}
});
3. 本地存储溢出
localStorage一般只有5MB左右,收藏多了可能存不下。现在只是try-catch吞掉了错误,以后真遇到了再考虑压缩或者换IndexedDB
最后
前后折腾了几天,现在能输BV号直接播、收藏视频、建歌单、整单播放或者挑几首追加到队列,也能加载B站视频的歌词
代码在GitHub上,有问题欢迎指出

留言
评论区暂未开放;可先通过 RSS 或「关于」页联系。