音乐播放器这个东西,做之前觉得”不就是放首歌吗”,做完之后发现——光 JavaScript 就写了1400多行。
为什么不用现成的

一开始用的是 APlayer,一个很流行的开源网页播放器。
但后来越用问题越多:样式改不动,想做毛玻璃风格很麻烦;跟 Astro 的 View Transitions 不兼容,页面一切换播放器就没了;想要的功能它也没有,双语歌词同步、封面高斯模糊什么的都得自己想办法。
所以决定自己写。BGM对我来说太重要了,追番篇里写过。
代码生成主要靠 Claude Code,我负责需求设计和代码审阅。但调试和修 bug 的过程真的痛苦。
浏览器不让自动播放
第一个大坑:浏览器的自动播放策略。
我想要的效果是:用户打开网站,滚动一下页面,音乐就自动播放。
结果发现 Chrome、Safari、Firefox 全部不允许。
它们的规则是:audio.play() 只能在”用户激活事件”里调用。点击、触摸算,滚动不算。
所以如果用户第一次交互是滚动,audio.play() 会被浏览器拒绝,而且是静默拒绝——不报错,就是不播放。
最后只能把”显示播放器”和”播放音乐”拆成两步。滚动只负责把播放器显示出来,真正的播放等用户点击或触摸的时候再触发。要是播放被浏览器拒了,就做个标记,下次用户随便点哪里的时候再偷偷重试一次。
这个 bug 前前后后修了好几版才稳定。“为什么音乐没有自动播放”这个问题我反复测了几十次,一度以为是自己代码写出了什么玄学 bug,折腾半天才发现——浏览器压根不让你播。
拖动不跟手
播放器是可以拖动的——按住然后拖到屏幕上任何位置。
第一版实现用的是直接改 left 和 top CSS 属性。能拖是能拖,但是很卡。特别是在手机上,手指滑动的时候播放器跟不上,有明显的延迟。
后来查了一下,left/top 属于”布局属性”,每改一次浏览器都要重新算一遍布局(reflow),所以才卡。
换成 transform: translate(x, y) + requestAnimationFrame 之后就好了。transform 不触发重排,浏览器可以拿 GPU 去算。改完之后丝滑了很多。
不过拖动还有另一个坑——拖动结束后如果手指释放的位置在播放器的按钮上,会触发按钮的点击事件。比如你拖完松手,刚好松在暂停按钮上,音乐就暂停了。
解决办法是在拖动结束后短暂屏蔽点击事件:在 capture 阶段注册一个 stopPropagation,然后 setTimeout(0) 后移除。
就一个拖动功能,前后翻了三遍。
歌词同步
双语歌词同步听起来很简单——按时间戳匹配当前歌词,显示出来就行。
但真做起来细节一堆。
时间戳是最烦的。每首歌的歌词都要手动标注每一句的时间,48首歌里有歌词的24首,每首十几二十行。对不上的时候就一行一行调——播放、暂停、微调0.3秒、再播放、还是不对、再调。纯体力活,干到后面脑子都是麻的。
滚动也有问题。歌词面板里当前行要居中显示,这用 scrollIntoView 就能做到。但要是用户自己在翻歌词,自动滚动就会跟手动滚动打架,画面一直在抢。
后来改成了:用户碰了滚轮或触摸之后,自动滚动先暂停 3 秒。3 秒内没有继续操作,再恢复自动滚动。
歌词面板打开/关闭的动画也调了一会儿。直接 display: none/block 切换太生硬,加了 opacity + transform + scale 的过渡,从下方滑入并放大,关闭时反向收缩淡出。
Apple Music 高斯模糊
后来想做 Apple Music 那种效果——播放器背景是当前歌曲封面的高斯模糊。
做法不算难:在播放器底层放一个跟封面一样的图片,用 CSS filter: blur(30px) 模糊掉,然后叠一层半透明的毛玻璃遮罩。
切歌的时候换一下背景图,每首歌的播放器颜色就不一样了。
但性能是个问题。filter: blur() 在手机上很吃性能,模糊值越大越卡。30px 的模糊在低端手机上直接掉帧。后来加了 transform: scale(1.5) 让模糊图放大超出容器,顺便把边缘的锐利截断也解决了。
歌词面板、字幕条也各自有一层封面模糊背景。三层模糊同时存在的时候,z-index 的层级关系得理清楚,不然会互相遮挡。
音量控制
音量滑块做成了竖向的——鼠标悬停在喇叭图标上方弹出。
但第一版有个 bug:拖动音量滑块的时候,因为鼠标事件冒泡到了播放器的拖动处理器,导致拖音量的时候整个播放器跟着一起跑。
修复方法是在音量滑块的 mousedown 和 touchstart 里加 stopPropagation(),阻止事件冒泡到上层的拖动逻辑。
还有个问题,音量滑块被播放器的 overflow: hidden 给裁掉了。滑块是往上弹出的,但播放器为了圆角设了 overflow: hidden,直接把滑块截了一半。最后改成 position: fixed + z-index: 9999,每次弹出的时候动态算位置,让它跟着喇叭按钮走。
这种”修一个小功能结果拔出萝卜带出泥”的事,整个开发过程中碰了无数次。到后面我都麻了,每次修 bug 都先给自己打个预防针:这个 bug 后面八成还藏着两个。
1400行
最后数了一下,音乐播放器相关的 JavaScript 居然有1400多行。
播放列表管理、歌词解析和同步、字幕条动画、完整歌词面板、拖动系统、音量控制、随机/顺序切换、视频联动暂停、页面可见性检测、View Transitions 持久化、自动播放策略兼容……
谁能想到”放首歌”这件事能堆出1400行代码。而且这些全塞在 Base.astro 一个文件里,快 1900 行了,编辑器每次打开都要卡好几秒。
但第一次在自己网站上听到音乐响起来的那一刻,还是挺激动的。坐在椅子上傻笑了好一会儿。
你可能还想看