⌨ 技术

液态玻璃音乐播放器:网页设计、数据体系与工程实践总结

前言

博客的 /music 页面上线了一个沉浸式的音乐播放器:液态玻璃质感、旋转的黑胶封面、随歌变色的氛围背景、可拖拽升降的歌词面板,背后挂着 91 首周杰伦的歌曲库(后来经 Ro 下载流水线扩至 241 首,见《无头音乐下载器 Ro 实战》),每首歌都有对应的专辑封面和带时间轴的滚动歌词。本文从网页设计、歌曲与封面数据、歌词对应、流式传输、听歌统计、性能优化几个维度,对整个实现做一次完整总结。

一、网页设计:Aura 液态玻璃体系

设计语言参照了 Apple 的 Liquid Glass 理念,并借鉴了开源项目 aura-music-player 的布局思路,全部用原生 CSS 实现,没有引入任何 UI 框架。

1. 布局体系

页面只有一个主容器,桌面与移动端共用 DOM、分道布局:

  • 桌面端:左 60%(封面 + 控制)/ 右 40%(歌词、播放列表双 Tab)的分栏,高度锁定为视口的 85%;
  • 移动端:播放器固定占满底层,歌词/播放列表是一个可拖拽的底部面板——初始高度 20%,可以拖到任意位置停留(上限 92%),点击把手在"完全展开"和"初始位置"之间切换。

一个容易被忽略的细节:两侧宽度不能只写在 grid-template-columns 上,因为子元素自身的 width 会覆盖列定义——桌面布局实际由 .mx-left { width: 60% } 这一条生效,改列宽时两处必须同步。

2. 动效体系

  • 随歌变色的氛围背景:三团 mix-blend-mode: screen 的大光斑(blur 100–150px),颜色由 CSS 变量 --aura 驱动,每首歌按序号分配一个主题色,切歌时整页氛围随之流转;播放时光斑缓慢缩放、旋转、漂移,暂停即静止。
  • 旋转专辑封面 + 水波涟漪:封面以 25 秒一圈匀速旋转,播放时三层波纹从封面向外扩散(border-radius 形变 + scale + 透明度渐隐),这是"液态"质感的点睛之笔。
  • 全部动效都尊重 prefers-reduced-motion,系统开启减弱动态时自动降级。

3. 响应式与视口

移动端的核心是分屏面板的高度管理,这里踩过移动浏览器视口的坑(详见上一篇《移动端 100vh 的坑》):所有高度统一锚定在 visualViewport.height 计算出的 --vv-h 变量上,并对底部悬浮地址栏做 --toolbar-comp 补偿,保证在 Chrome、Edge 底栏模式等各类浏览器上面板都不被遮挡、位置始终一致。

二、歌曲数据:一份 manifest 走天下

音乐库的唯一数据源music/manifest.json,共 91 首,每首歌一条记录:

{
  "file": "t01.mp3",
  "title": "I Do-周杰伦",
  "album": "太陽之子",
  "cover": "/static/images/covers/sun-son-2025.jpg",
  "lrc": "/static/lyrics/t01.lrc"
}

file 指向音频、title 按"歌名-歌手"约定、album 用于展示、coverlrc 分别指向静态资源。播放器页面的所有行为(封面、歌词、列表、队列缩略图)都从这一份数据驱动——后台想加歌,只要往 music/ 放音频并按格式追加一条 manifest 记录,页面自动获得封面和歌词能力,前端零改动。

这套格式是在实践中演化出来的:最早的版本只有 filetitle,封面和歌词靠前端硬编码映射表;歌一多映射表就失控了,于是把元数据下沉到 manifest,前端只认字段。数据与逻辑分离后,新增歌曲的成本从"改前端代码"降到了"改一份数据文件"。

三、歌词对应:多源抓取 + 全量校验

91 首歌词来自三个渠道,各有分工:

  1. 用户自带的 LRC 文件:整理音乐时顺手准备的,直接复用;
  2. 酷歌词(kugeci.com):站内搜索 → 解析结果表格(歌名 + 歌手双条件匹配原唱,避免拿到翻唱版)→ 从歌曲页提取内嵌的 LRC 文本;
  3. QQ 音乐接口:搜索拿 songmid → 歌词接口返回 base64 编码的 LRC,解码即得。QQ 音乐握有周杰伦版权,kugeci 缺失的原版基本都能在这补齐。

抓取之后做了一次全量校验:把每份本地 LRC 的内容行集合与 QQ 音乐官方歌词比对,重叠率低于 60% 判定为错误并自动替换(原文件留 .bak)。这一步查出了 5 份错误歌词——包括混入了其他歌手作品的《明明就》——全部修复,最终 89/91 首歌词正确(剩余 2 首是纯音乐钢琴曲,本来就没有词)。

校验脚本 verify_lyrics.py 保留在了项目里,以后新增歌曲后重跑一次就能做质量兜底。

前端的歌词体验

  • LRC 解析支持一行多时间标签,空行、元数据自动跳过,按时间排序;
  • 播放时当前句白色加粗放大、已过句半透明、未到句更淡,自动平滑滚动居中,容器上下用 mask-image 渐隐;
  • 点击任意歌词行跳转到那句;
  • 没有歌词的曲目显示优雅空态("纯音乐,请静静欣赏")。

四、流式传输

音频通过 /music-stream/<文件名> 提供,Flask 端一行关键参数:

send_file(path, mimetype='audio/mpeg', conditional=True)

conditional=True 让 Flask 自动支持 HTTP Range 请求——这是拖动进度条、边下边播、断点续播的基础。没有它,<audio> 元素的 seek 会退化成"必须下载完整文件"。

另外两点配套:

  • 文件名安全:接口校验扩展名并拒绝路径分隔符,防止目录穿越;
  • 音乐清单内联:91 首清单由服务端渲染时直接注入页面(window.MUSIC_LIST),打开页面即有全量数据,歌名、封面零延迟渲染,不存在等接口的空窗。

五、状态与续播:跨页面无缝听歌

播放状态(当前曲目、进度、是否播放中)持久化在 localStoragemp_state_v1 键里,这个协议被博客全站的音乐引擎和播放器页共享:

  • 在播放器页听到一半切去博客文章页,文章页的音乐引擎读取同一份状态,同一首歌同一进度无缝续播
  • 播放器页加载时也先读缓存,立即渲染上次的歌名/封面,再与列表对齐。

这是一个零成本的"状态机同步"设计:不引入任何后端存储或消息机制,仅靠一份约定格式的本地状态,就让多个独立页面表现为同一个播放器。

六、听歌统计:谁在和你一起听

曲库扩容之后,播放器的控制排加入了第五个按钮——统计图标(柱状图),点击弹出一块液态玻璃信息框,回答三个问题:有多少人正在听、有多少人来听过、大家一共听了多久

三组数据的口径

  • 用户数(今日 / 本月 / 累计):完全复用站点访问统计的体系——页面打开时上报一次访问,visitor_key = sha1(IP + UA)日去重算 UV,总数按「每天独立去重再求和」的人次口径累计,与 /blog 的访客统计同一把尺子;
  • 播放时长(今日 / 本月 / 累计):每个访客每天一行聚合记录(date + visitor_key 唯一),心跳只做秒数累加,查询时按日期范围 SUM,存储量与访客数线性相关、与听多久无关;
  • 在线人数:内存里维护一份「最后心跳时间 + 是否在播」的名单,90 秒没有心跳视为离线,统计窗口内播放器处于播放状态的访客数。口径是页面级的「有多少人在这个页面听歌」,以访客为单位,与具体哪首歌无关。

采集端:只计"真实在听"的秒数

客户端每秒检查一次「音频在播且页面可见」才累计 1 秒——切到后台标签页、暂停、锁屏都不算数。攒满 30 秒立即上报一次,另有 30 秒保底心跳维持在线状态;播放与暂停事件各自即时触发一次心跳,让在线名单里的「正在播」状态以毫秒级跟上真实操作,而不是等下一次周期心跳(否则一个人点了播放,最多 30 秒后统计才承认他在听);切后台或关闭页面时通过 keepalive 的 fetch 把零头补报出去,不丢数据。服务端对单次上报钳制在 120 秒以内,防刷。整个上报是 first / playing / listened 三个字段的 JSON,CSRF 豁免一个端点即可。

两个绕不开的坑

  • 弹框被面板裁剪.glass-paneloverflow: hidden 会裁掉一切超出面板的子元素,统计框在按钮下方展开时正好被砍掉下半截。解法是改为向上展开bottom: calc(100% + 14px)),上方是封面大区域,空间永远够;
  • 白玻璃看不清字:白色高透明玻璃叠在随歌变色的彩色背景上,文字和背景糊成一片。解法是换成深色玻璃——rgba(16,16,22,.72) 叠加渐变高光与内描边,背景色彩依旧透出,文字对比度接近实底。

另外移动端控制排原本把「播放模式」按钮绝对定位钉在最左、其余按钮居中,加入第五键后播放键不再居中、整体看着偏左;修复方式是去掉绝对定位,五键进 flex 流等距居中,与桌面端同一套布局。

七、移动端性能:氛围光效与第二屏的掉帧排查

功能全绿之后,真机上暴露了一轮性能问题:桌面端一切丝滑,手机上切歌时光效卡顿、拖动第二屏时不仅卡,背后的光斑还会闪烁。桌面 GPU 无感、移动 GPU 掉帧——这类问题的排查思路和结论都值得记下来。

三层根因

  1. 切歌掉帧:颜色过渡是"重绘动画"。氛围色切换时,3 个大模糊光斑的 background 颜色做 1.2 秒过渡。background/color 的过渡不是合成器动画——每一帧都要走"重绘元素位图 → 重新执行 100~150px 高斯模糊 → 重新做混合合成"的完整管线,1.2 秒 × 60fps ≈ 70 次大表面重绘,手机 GPU 直接被冲垮。
  2. 拖动闪烁:backdrop-filter 在逐帧变化的内容上是奢侈品。第二屏面板挂着 backdrop-filter: blur(48px),含义是"面板背后的一切每帧重新取样、重新模糊"——而拖动时面板高度逐帧变化,背后偏偏又是动画光斑。GPU 每帧重采样 + 重模糊一大块不断变化的区域,纹理反复分配/逐出,光斑层被逐出的那一两帧就会闪烁
  3. 高频 pointermove 逐事件布局:120Hz 屏上 pointermove 每秒触发 120+ 次,旧实现每个事件都改一次面板高度并同步一个 :root CSS 变量——一帧之内可能做两三次全量样式失效 + 面板子树重排(播放列表有 236 个节点)。

解法四件套

  • 面板实底化:移动端去掉 backdrop-filter,换成高不透明深色底。逐帧的 backdrop 重模糊直接消失,歌词可读性反而更好(桌面端保留玻璃,桌面 GPU 无压力);
  • 光效静态化:3 个动画光斑替换为两层静态三色渐变(radial-gradient),换歌时两层用 opacity 交叉淡化——opacity合成器动画,GPU 只做透明度混合,零重绘、零重新模糊;桌面端保留动画光斑(CSS 分端隐藏同一 DOM);
  • 拖动 rAF 合并:高频 pointermove 只记录最新位置,requestAnimationFrame 里每帧至多执行一次高度变更;拖动过程中不同步 :root 变量,缩小样式失效范围,松手吸附时才同步;
  • 重排隔离:contain: layout paint 把第二屏内部的重排(236 首的列表)关在面板里,不外溢。

原则

这一轮排查沉淀出三条判断:opacity/transform 是合成器动画,background/color/尺寸是重绘动画,能用前者绝不基于后者做过渡;backdrop-filter + 会逐帧变化的内容 + 移动端,三者同时出现基本必卡;以及闪烁往往不是绘制 bug,而是 GPU 层被逐出重建的症状——看到闪烁先查纹理压力,不要急着改绘制逻辑

八、优化清单

  • 拖拽跟手:面板拖动过程中禁用过渡动画(像素级跟手),松手吸附到端点时才启用缓动;pointerdown 时清除残留的过渡类,连续快速操作不卡顿;
  • 面板与播放器解耦:播放器区高度固定预留(不与面板高度联动),面板升降只是"盖上来",播放器尺寸位置恒定不变形,也杜绝了误触;
  • 视口补偿--toolbar-comp 变量自动排除底部悬浮地址栏的遮挡,并挂载 load / pageshow / visibilitychange / resize / visualViewport.resize 多时机同步,兼容 Edge 底栏模式这类"首帧后才确定视口"的浏览器;
  • 禁用滚动恢复:全屏固定布局的页面没有有意义的滚动恢复,history.scrollRestoration = 'manual' 保证每次进入都在正确的初始位置;
  • 资源缓存:静态资源用版本参数(?v=yyyymmddX)控制刷新,改样式必升版本,用户端无需清缓存;
  • 队列封面懒加载:曲库过百后播放列表的一次性全量缩略图(约 10MB+)不可接受,缩略图只带 data-srcIntersectionObserver 临近视口 300px 才真正请求,老浏览器降级为全量;"Up Next" 标题附带曲库总数。
  • 预取下一首:开播 5 秒后静默预取队列下一首的音频与歌词进 HTTP 缓存(洗牌换目标时用 AbortController 中止旧请求),切歌近零缓冲。

结语

这个播放器最终的代码量并不大:模板 150 行、CSS 700 行、JS 600 行,加上两份数据文件。它能撑起完整的体验,靠的是三个设计决策:一份带元数据的 manifest 作为唯一数据源一套 CSS 变量驱动的视口基准一份跨页面共享的播放状态协议。前者让内容运营与前端解耦,后两者让多浏览器适配与多页面协同变成声明式配置而不是逐个修补。

如果说有什么遗憾,是歌词目前依赖外部抓取,版权上更适合挂接正式的歌词服务;以及封面来自 iTunes CDN 的官方素材,如果未来要完全自主可控,可以在后台上传歌曲时支持自定义封面上传。这些都留给了下一个迭代。

评论区

共 0 条
🍃 还没有评论,沙发虚位以待~
🚇

穿越虫洞?

虫洞会把你随机传送到另一位陌生博主的网站——
可能是技术大佬,也可能是诗歌爱好者,开盲盒的时间到了。

森林精灵