返回首页

Heartless Radio 开发经历

记录 Heartless Radio 从想放歌到角落里真的在响的全过程:技术选型、架构演进与踩过的坑。

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

留言

--

正在加载留言…