Heartless Radio 是 The Heartless Corner 首页底部的一个常驻音乐播放器。固定在页面底部,循环播放我挑选的歌单。
最开始是在别人的网站上看到可以播放音乐。我自己本身也比较喜欢音乐,所以也想在自己的网站上分享自己喜欢的歌。
技术栈一览
Heartless Radio 分三层,每层独立选型:
本体(用户看到的部分)
固定在底部的 dock 播放条,集成在 BaseLayout.astro 里,跟随每一页渲染。播放/切歌/进度环都是原生 DOM 操作,零运行时框架依赖,不增加额外 bundle。
选原生而非 React 的核心原因:播放器只有一个组件、一个状态(当前曲目 + 进度),没有复杂的组件通信。塞进 React 只会让一个几百行的交互变成几十 KB 的 hydration 负担。
数据(歌单 & 元数据)
- 歌单缓存:Cloudflare KV,播放器运行时拉
/api/content/music,缓存 30 秒,避免每次访问都打数据库。 - 上传元数据:Neon PostgreSQL 的
cms_music_uploads表,记录每首歌的标题、作者、封面、存储键。
上传 & 托管
- 上传:GitHub Contents API 直传到
public/music/(幂等键优先),MUSIC_STORAGE=b2时走/audio/*B2 代理兜底。 - 音频托管:Cloudflare R2,bucket 名
heartless-radio,绑定自定义域media.heartlesscorner.com。 - 转码:
scripts/optimize-music.mjs用 ffmpeg 统一压到 128kbps CBR(原子替换,没变小就跳过)。
功能架构
用户访问任意页面
├── BaseLayout 注入底部 dock(仅当有歌单时才占位,空歌单页面保持贴底)
├── 运行时拉 /api/content/music(KV 缓存 30s)
├── 渲染歌单 → 封面 + 进度环 + 播放/切歌
├── 用户点击播放/切歌 → 进度环实时走
└── CMS 后台保存 → 歌单立即生效(无需重新构建)
歌单的两种来源
- 构建时快照:构建阶段生成 DOM 兜底,即便运行时接口挂了也能播。
- 运行时清单:播放器启动后拉实时歌单,CMS 里改了什么就播什么——这是我后来加的关键能力。
版本演进
Heartless Radio 经历了 3 个阶段:
v1 — 能播就行
最早只是一段能播、能切、能显示封面的播放器。歌单写死在构建时的数据文件里,想加一首歌就得改文件、重新构建、重新部署。能用,但每次加歌都像搬一次家。
视觉上也很笨拙:底部 dock 直接 fixed 贴底,进度环用写死尺寸对着封面,差几个像素,怎么看怎么歪。
v2 — 让它流动起来
这是架构上的关键转折——从「构建时写死」变成「运行时拉取 + 后台可改」。
做了上传管线:一套能直接在 CMS 后台传音频、自动写元数据、幂等防重复的机制。上传走 GitHub 接口直存,后面也接了 B2 做代理兜底。播放器改成运行时拉清单,CMS 保存即生效。
那一刻我意识到,这台收音机终于不是焊死的了,而是能我自己随时往里塞碟。
v3 — 搬个家
早期音频跟着仓库一起走。能用,但随着歌变多,仓库越来越沉,大陆访问速度也时好时坏。
2026 年 8 月,我把音频整体搬到了 Cloudflare R2,建了 heartless-radio 桶,绑了专属域名 media.heartlesscorner.com。线上清单全部切到 R2,仓库里只留封面。音频托管和代码仓库彻底解耦——以后加歌不动仓库,加载更快,访问更稳。
核心实现
进度环对齐
最早进度环是写死 px 尺寸 + !important,跟封面永远差几像素。改成绝对定位铺满封面,任意屏幕下都贴死:
.music-dock__compact-ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
底部 dock 预留
dock 是 fixed 贴底的,长页面正文最后几行会被它吃掉。用 :has() 在 dock 真有歌时才给 <main> + <footer>(含评论区、相关文章)预留底部高度,空歌单页面保持贴底:
/* 仅在 dock 有内容时预留,避免空页面出现怪异留白 */
body:has(.music-dock--active) main { padding-bottom: 64px; }
body:has(.music-dock--active) .post-comments,
body:has(.music-dock--active) .related-posts { padding-bottom: 64px; }
运行时歌单拉取
播放器启动后不依赖构建快照,直接拉实时清单,KV 缓存 30 秒:
const res = await fetch('/api/content/music');
const { tracks } = await res.json();
// KV 已缓存 30s,重复访问不打数据库
renderPlaylist(tracks);
R2 manifest 生成
上线 R2 后,清单里的音频 URL 全部走 https://media.heartlesscorner.com/<name>.mp3。域名通过 wrangler.jsonc 的 vars 注入,脚本读取 process.env.MUSIC_CDN_BASE:
const CDN = process.env.MUSIC_CDN_BASE; // = https://media.heartlesscorner.com
manifest.tracks.forEach((t) => {
t.audio = `${CDN}/${t.file}`;
});
踩过的坑
我把几个比较典型的坑列出来——有些本地完全测不出,是上线后才暴露的。
1. dock 遮挡正文
DOM 正常渲染,但文章最后几行被底部 dock 盖住。原因是 fixed 元素不占文档流,正文滚到底就被它挡。解法是用 :has() 预留底部高度(见上)。后来又发现评论区和相关文章渲染在 <main> 之外,也得单独预留,否则 dock 照样盖住文章尾巴。
2. 进度环错位
写死 px + !important 的进度环在 Retina 和不同字号下都对不齐封面。改成 inset:0 + 100% 自适应后才彻底解决。
3. B2 美西节点太慢
早期试过把音频放 B2 走 /audio/* 代理,结果美西节点对大陆延迟太高,播放卡顿。2026-08-07 放弃 B2 默认方案,改走 R2(亚太节点 heartless-radio 在 WNAM),体验才顺。
4. R2 直传只落本地模拟器
用 wrangler r2 object put 传音频,云端桶却始终是空的。原因是没加 --remote,文件只传进了本地模拟器,云端桶没动。加上 --remote 才真正落地。
5. MUSIC_CDN_BASE 被回滚
只在 Cloudflare Dashboard 设了 MUSIC_CDN_BASE 环境变量,结果下次部署被仓库里的 wrangler.jsonc 覆盖回滚,清单又变回 GitHub 本地路径。根因:Pages 构建时 wrangler.jsonc 的 vars 才是真源。必须写进 wrangler.jsonc 的 vars,Dashboard 设了没用。
6. 歌单写死导致的构建依赖
v1 的歌单写死在构建数据里,加一首歌就要重新构建整站。改成运行时拉取 + 构建快照兜底后,加歌和部署彻底解耦——这是整个项目最值得的一笔重构。
资源清单
| 资源 | 说明 |
|---|---|
| 音频托管 | Cloudflare R2 heartless-radio,自定义域 media.heartlesscorner.com |
| 封面 | 仍走仓库 public/music/covers/(图片体积小,跟站走更简单) |
| 转码规格 | ffmpeg 128kbps CBR,原子替换 |
| 歌单缓存 | Cloudflare KV,30 秒 |
| 上传通道 | GitHub Contents API 直传 public/music/,B2 仅 MUSIC_STORAGE=b2 时启用 |
最后
Heartless Radio 看起来是博客里最「安静」的部分——没有盲盒、没有梗图、没有摸鱼评级,就一小条播放器待在角落。但它的工程并不少:从运行时歌单拉取到 R2 跨域托管,从上传幂等到转码原子替换。
这大概就是它的本质:用正经技术,在博客里做一件很安静的事。
如果你对某部分的技术细节想了解更多,或者想点歌,欢迎在评论区留言。
Conversation
留言