侧边栏多了个播放器:
┌──────────────────────────┐│ ▍音乐 ││ ⬤ 曲名 ││ 歌手 ││ 0:00 / 4:45 🔊 ▬▬ ││ ────────────────────── ││ ⇄ ⏮ ▶ ⏭ ☰ │└──────────────────────────┘配色跟随你的主题色,自动适配明暗模式。
先说结论不需要部署任何后端服务。
歌单在构建时抓取,音频由访客浏览器直接向网易云 CDN 取流——不占你的服务器带宽,也不多一个要维护的服务。
一、原理
先说清为什么能不用后端,这决定了后面所有的限制。
传统做法(比如 Meting)要搭一个代理,是因为:
- 网易云歌单接口没有 CORS 响应头,浏览器里的
fetch会被拦 - 所以要有个服务端帮忙转发
但有两个事实让代理变得没必要:
① <audio> 标签不受 CORS 限制。
网易云有个公开的外链接口:
https://music.163.com/song/media/outer/url?id=<歌曲ID>.mp3访问它会 302 跳到真正的 CDN 地址。<audio src="..."> 加载它不需要任何跨域许可——HTML 媒体元素本来就允许加载跨域资源(只有用 Web Audio API 处理音频数据时才需要 CORS)。
而且它不校验 Referer,带不带都能跳转。
② 歌单接口可以在构建时抓。
浏览器不能直接调,但 Node 里没有跨域限制。构建的时候抓一次,把曲目列表直接烤进 HTML 就行了。
于是整个链路变成:
构建时(你的服务器) fetch 歌单 → 曲目列表写进页面 ↓运行时(访客浏览器) <audio src="music.163.com/song/media/outer/url?id=X.mp3"> 直接向网易云 CDN 取流代价是:往歌单加歌之后,得重新构建一次博客才会同步。做不到实时。如果非要实时,那就得回去搭后端了。
二、前置条件
- 一个 Fuwari 主题的博客(Astro 5 + Svelte 5)
@iconify/svelte和@iconify-json/material-symbols(Fuwari 自带,不用装)- 一个公开的网易云歌单
三、部署步骤
第 1 步:拿到歌单 ID
打开你的网易云歌单,分享链接长这样:
https://music.163.com/#/playlist?id=1234567890 ^^^^^^^^^^最后那串数字就是歌单 ID。歌单必须是公开的,否则接口拿不到。
第 2 步:放入两个文件
在 src/components/widget/ 下新建两个文件:
src/components/widget/├── MusicPlayer.astro # 构建期抓歌单 + 外层卡片└── MusicPlayerClient.svelte # 客户端播放器文件内容见仓库(文末有链接)。
第 3 步:改歌单 ID
打开 MusicPlayer.astro,顶部有一行:
// 网易云歌单 ID(改成你自己的即可)const PLAYLIST_ID = "你的歌单ID";把 你的歌单ID 换成第 1 步拿到的那串数字。
第 4 步:挂进侧边栏
打开 src/components/widget/SideBar.astro,导入组件:
import MusicPlayer from "./MusicPlayer.astro";然后放到你想放的位置。位置就是数组里的顺序,比如放在「分类」和「标签」中间:
<Activity class="onload-animation" style="animation-delay: 120ms"></Activity><Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
<MusicPlayer />
<Tag class="onload-animation" style="animation-delay: 200ms"></Tag>第 5 步:本地构建测试
pnpm build构建日志里不会专门提这个组件。想确认歌单抓到了没有,构建完查一下产物:
grep -c "music.163.com/song/media/outer/url" dist/index.html返回大于 0 就说明抓到了。
然后起个预览看看效果:
pnpm preview打开 http://127.0.0.1:4321/,侧边栏应该就有播放器了。
图标可能会晚几秒才出现
@iconify/svelte默认在运行时去api.iconify.design请求图标数据(实测要 3 秒左右)。所以首次打开时,控制栏那几个按钮会先显示成空白,几秒后图标才蹦出来。之后有浏览器缓存就快了。想彻底消除的话,可以在构建期把这 11 个图标内联进页面(见「进一步优化」)。
第 6 步:部署
跟平时一样构建部署就行,没有任何额外步骤。
如果你的部署是 git push 自动构建(比如用钩子/CI),那就更简单——推一次代码,服务器构建时会自动抓最新歌单。
四、日常使用
加歌的流程:
网易云里收藏到歌单 ↓重新构建 + 部署(推送一次) ↓博客上的歌单更新记住那个限制:歌单是构建时抓的。光在网易云加歌不会自动生效。
五、几个实现细节
如果你要改样式或者复用这套思路,这几处值得看一眼。
歌单展开的动画
用 grid-template-rows: 0fr → 1fr 做高度过渡,不需要预先知道内容高度:
.mp-drawer { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);}.mp-drawer.open { grid-template-rows: 1fr; opacity: 1;}.mp-drawer-inner { overflow: hidden; min-height: 0; /* 必须,否则 grid 子项不会收缩 */}比 max-height: 0 → 999px 好:后者要么动画速度忽快忽慢,要么得猜一个足够大的值。
封面旋转
播放时转,暂停时停在当前角度:
.mp-cover img { animation: mp-spin 12s linear infinite; animation-play-state: paused; /* 动画常驻,只是暂停 */ transform-origin: center;}.mp-cover.spinning img { animation-play-state: running;}要点是动画一直挂着,只切换 play-state。如果直接增删类名,animation 会被移除,旋转角度立刻重置回 0°。
播放状态由事件驱动
playing 只由 <audio> 的 onplay / onpause 事件驱动,不做乐观赋值——否则 play() 被拒(自动播放策略、网络失败)时,界面会卡在”正在播放”但一点声音都没有。
拖动进度条
用 Pointer 事件 + setPointerCapture:
el.setPointerCapture(e.pointerId); // 指针移出元素后仍能继续拖没有这一句,鼠标一旦拖出那个细条,拖动就断了。触屏上还要给滑块加 touch-action: none,否则会误触发页面滚动。
错误处理
网易云有些歌因为版权、地区限制或下架,外链接口会拿不到地址。<audio> 的 onerror 里要跳到下一首,并累计失败次数——全部失败时停下,否则会无限循环。
六、已知限制
| 限制 | 说明 |
|---|---|
| 加歌要重新构建 | 歌单构建时抓取,无法实时同步 |
| 图标首次加载慢 | 见上面那个警告 |
| 不预检歌曲可用性 | 构建时不会逐首探测,失效的歌由前端运行时跳过 |
| 只在 Fuwari 上测过 | 其他 Astro 主题需要自己调整样式变量 |
七、进一步优化
如果想让首次加载的图标立刻出现,可以在构建期把用到的图标内联进页面:从 @iconify-json/material-symbols/icons.json 里取出用到的十几个图标,通过 addCollection() 喂给 @iconify/svelte,页面里就不用再向 Iconify API 发请求了。
八、源码
两个文件,加起来不到 500 行:
MIT 协议,随便拿走用。
关于本文本文由 AI 完成,方案和代码都在我的博客上真实运行。
辽公网安备21021702000925号