从播放器到音频终端:音乐页这一轮返工记录
本来只是想给音乐页换一个“更像 Rhine 的样子”,结果一路修到了字体、导航栏、音量弹窗、FLAC 转码、歌词标签和 LRC 匹配。
这篇文章记录今天实际做了什么、踩了哪些坑,以及为什么“看起来像”和“动起来像”是两件完全不同的事。
一、起因:只换配色是不够的
最开始,音乐页已经能从管理员后台上传本地音频,也有一套播放器 UI。
后来参考了 Rhine Music Database 的视觉风格,把页面改成了黑金/纸张橄榄色的“音频终端”:曲目表、编号、歌词面板、播放队列、底部播放条。
刚改完时,静态截图看着还行,但很快发现几个问题:
- 切页面时字体会“弹”一下
- 点导航后导航项会整体跳位
- 音量弹窗鼠标一移动就消失
- 上传 FLAC 后歌词抓不到
- 原版那种“唱片墙”的动态感完全没有
于是今天基本就在做一件事:把“看起来像”往“用起来也对”推进。
二、音乐页现在是什么样
现在 /music 是一个只播放本站后台上传音频的本地音频终端:
- 曲目 / 歌手 / 专辑三种索引
- 播放队列、顺序 / 单曲 / 随机
- 同步歌词、点击歌词跳转
- 音量、静音、进度拖动
- 系统媒体控制
- 切到其他页面音频不中断
- 首页的小播放器和全站悬浮播放器共享播放状态
公开的网易云播放接口已经移除,前台不会再去请求第三方曲库。
现在播放源只有一处:data/music.json + data/music/。
三、字体:从“分片加载”到自托管
现象
从首页切到项目、归档等页面时,文字会突然变一下,像是字体“弹”了一下。
原因
原来用的是 Google Fonts 版 Noto Serif SC。中文字体会被拆成很多个 unicode-range 分片,只有页面用到某些字时才加载对应的分片。
于是切换页面时:
- 新页面出现新的汉字
- 浏览器请求新的字体分片
- 文字从 fallback 字体切成 Noto Serif SC
- 视觉上就是“字体弹了一下”
处理
改成自托管单文件:
NotoSerifSC-400.woff2
NotoSerifSC-700.woff2
NotoSerifSC-900.woff2
三个字重合计约 6.1MB,首次加载后全站缓存。切换页面不再加载新的字体分片。
同时把页面切换动画从“上移 20px + 0.8 秒渐入”改成了 0.35 秒纯淡入,减少整页“弹一下”的感觉。
代价是首屏字体体积变大,但换来稳定的字体渲染。算是一次明确的取舍。
四、导航栏:不是动画问题,是每一页都在重新挂载
现象
点击导航后,导航项会突然缩一下,整体位置跳变;手机端圆盘也会重新分布。
原因
Navbar 之前在每个页面里单独挂载。每次切页:
- 新页面重新创建一个 Navbar
- 重新请求
/api/nav - 接口返回前先渲染全部 10 个导航项
- 返回后隐藏空的「说说 / 杂谈 / 友链」
- 导航项从 10 个缩成 7 个,整体跳位
处理
- 把 Navbar 提升到根布局,全站只挂载一次
- 删除 12 个页面里重复的
<Navbar /> - 把
/api/nav的结果缓存到sessionStorage - 首屏默认按当前已启用板块渲染,避免 10 → 7
- 路由变化时关闭手机圆盘、复位拖拽球和旋转角度
/admin自动隐藏站点导航
在 /api/nav 人为延迟 1.5 秒的情况下,切页前后导航项数量和位置都保持一致。
五、音量弹窗:8px 的空隙
音量弹窗原来用 onMouseLeave 关闭,判断区域只有音量按钮本身。弹窗在按钮上方,两者之间有大约 8px 的空隙。
鼠标从按钮移向弹窗时,会先穿过这段空隙,mouseleave 立刻触发,弹窗就消失了,根本来不及调音量。
现在改成:
- 点击弹窗/按钮外部关闭
- 再点一次音量按钮关闭
- 按
Esc关闭 - 点击、拖动滑块不会误关
不再依赖鼠标离开判定,空隙也就不再是问题。
六、音频上传:批量 + 自动标签
后台的「音乐」页现在是批量上传:
- 一次可选多个音频
- 逐个上传,避免单请求体积过大
- 上传后自动读取内嵌标签:
- 标题
- 歌手
- 专辑
- 年份
- 时长
- 内嵌封面
- 内嵌歌词
- 没有标签时用文件名推断
- 内嵌封面保存到本地,通过自己的封面接口提供
- 删除曲目时同步删除本地音频和封面
单个文件上限 150MB,Nginx 请求体上限调到 160MB。
七、FLAC 太大:后台自动转 MP3 320
上传 FLAC 后,后台会用 ffmpeg 自动转成 MP3:
- 默认 320kbps CBR
- 双声道
- 超过 48kHz 时降到 48kHz
- 转码前先读取标签、封面、歌词
- 转码成功后默认删除原始 FLAC
实测:
36MB FLAC → 7.06MB MP3,约 2.8 秒
71MB WAV → 16.8MB MP3,约 4.4 秒
为什么用 MP3 而不是 AAC?
- AAC 320 与 MP3 320 体积基本相同
- 测试发现无头 Chromium 不带 AAC 解码器
- MP3 兼容所有主流浏览器
320kbps MP3 对个人日常聆听已经足够接近透明。如果需要保留母带,可以设置环境变量保留原始文件,但会继续占用空间。
八、歌词:为什么 FLAC 的 LYRICS 抓不到
这是今天最意外的坑。
旧代码只读:
metadata.common.lyrics[0].text
但实际测试发现:
- FLAC 的
LYRICS纯文本会被music-metadata解析成空的syncText,原文丢失 UNSYNCEDLYRICS、UNSYNCED LYRICS、LYRIC这些标签根本不会进入common.lyrics
新逻辑改成优先读 native 原始标签:
- FLAC / Vorbis:
LYRICS、UNSYNCEDLYRICS、UNSYNCED LYRICS、UNSYNCED_LYRICS、LYRIC - MP3 ID3:
USLT、ULT、SYLT、TXXX:LYRICS - M4A:
©lyr - APE:
Lyrics
然后再读 common.lyrics,保留 LRC 时间轴,纯文本歌词也会保留并在页面上显示成 TXT / 纯文本歌词,不再被丢掉。
九、LRC 批量匹配:按歌名补歌词
后台新增了「批量匹配歌词」:
- 可一次选择多个
.lrc/.txt - 支持 UTF-8、UTF-8 BOM、GBK、UTF-16
- 文件名匹配:
歌名.lrc歌手 - 歌名.lrc01. 歌名.lrc
- 先按完整「歌名 - 歌手」匹配
- 再按纯歌名匹配
- 最后做唯一的包含匹配
- 多歌手分隔符
/、,、&、、、+会统一处理 - 匹配成功写入曲库,列表显示「歌词已匹配」
- 匹配不到会提示先上传音频,或把 LRC 重命名为歌名
有一点很重要:
LRC 只给已上传的曲目补歌词,不会根据歌词文件创建新歌。
所以如果提示「没有找到同名歌曲」,通常不是文件坏了,而是对应音频还没上传,或者曲库里的歌名和文件名不一致。
十、网易云接口:只给管理员补元数据
保留了网易云接口,但只放在管理员后台:
- 需要管理员登录
- 只返回标题、歌手、专辑、年份、封面、时长
- 不返回音频播放地址
- 不对访客开放
- 前台公开的网易云播放路由已删除
后台编辑曲目时可以搜索并补全元数据。补全的封面会在保存时自动下载到本地,不会让前台直接引用网易云外链。
十一、来源声明
在关于页底部新增了「音乐页来源与音频版权声明」:
- 视觉与交互参考 Rhine Music Database(MIT License)
- 未直接复制其源代码或美术素材
- 前台不接入第三方曲库播放
- 全部曲目由站长后台上传
- 管理员后台仅使用网易云公开接口查询元数据
- 音频/封面/歌词版权归各自权利人
直达链接:
/about#music-credit
十二、还没做完:原版的动态效果
这一点必须承认:
现在的 /music 复刻了配色、排版和一部分播放交互,但没有复刻原版的 3D 录音带/CD 唱片盒墙。
原版的动态效果不是 CSS 斜纹,也不是粒子或音频频谱,而是:
- 5 条纵深排列、互相重叠的 CD 唱片盒墙
- 约 3° 的超长焦镜头 + 移轴投影
- 距离雾化 + 纸张感
- 选中唱片盒抬升、相机推进
- 光盘 20 秒一圈旋转
- 悬停抬升、拖拽惯性、吸附、涟漪、主题波
它没有 bloom、没有粒子、没有音频频谱。
“高级感”来自 3D 空间、镜头和弹簧,而不是堆特效。
今天已经把可行性、架构、源码复用方式、性能预算和工期写成了技术报告。下一步先做静态 3D 骨架验证:
160 个简化唱片盒实例
+ 3° 长焦相机
+ 移轴
+ 雾
+ 右侧 300px 渐变遮罩
先看一张截图是否已经“像原版”,再决定是否投入完整的选中卡片、光盘和拖拽交互。
十三、今天的状态
- 生产服务已重新构建并重启
- 音乐页、后台、关于页正常
- 曲库中的 10 首曲目都保留着
- 音频已压缩为 MP3
- 原有曲目的歌词需要补
.lrc,或重新上传带歌词的原始 FLAC - 字体、导航、音量弹窗问题已修复
- 3D 动态效果还没开始实现,技术报告已完成
十四、明天
明天优先做音乐页的 3D 动态骨架,不再继续在 CSS 背景上加纹理。
如果这一步验证通过,再继续做:
- 选中唱片盒
- 相机推进
- 光盘旋转
- 拖拽与惯性
- 主题波和涟漪
- 移动端降级
希望下一次更新的时候,音乐页不只是“像 Rhine”,而是真的能在里面拖动那面唱片墙。
本文由暮色太阳花整理。音乐页视觉参考 Rhine Music Database,相关来源与版权说明见 关于页。
