给 Spotify 添加歌词的折腾事

5,094 阅读7分钟

Spotify 的 WebApp 做得非常优秀,作为一个 Web 前端开发,我非常喜欢用 Sptify。早在 Spotify 还只支持 ChromeOS 安装成 PWA 时我就用 DevTools 将 Spotify PWA 安装在 macOS 上。但由于版权等等不明原因 Spotify 迟迟没有支持歌词,对我而言有点小小的遗憾。

Chrome 发布 PiP 后不久 Spotify 就应用上了—— PiP 中展示高清(640x640😅)封面图。有一天上班摸鱼时我看着专辑封面,突然想到,要是能将歌词显示到这个 PiP 窗口上那不是很酷?直接全局窗口置顶,直接吊打其他任何 Chrome 扩展,想了一个实现方案,自己懒得动手,打算把想法丢到 Firefox 贴吧看有没有老哥做一下,后来想了一下,国内这么多“好用”的免费音乐播放器哪里还有我这样的奇葩用 Spotify 呢,于是自己动手做了起来。

// 扩展链接

初步实现

就像贴吧中写的一样,实现方式是这样的:

  1. 使用浏览器扩展的 ContentScript 在页面加载前重写 document.createElement 捕获 <video> <audio> 元素
  2. 使用 NeteaseCloudMusicApi 根据歌名歌手信息拿到歌词
  3. 将歌词写到 <canvas>,并捕获成流
  4. 将 Canvas 流合并到 Spotify PiP 视频流,替换视频原
  5. 监听播放器,当当前播放歌曲改变时刷新歌词
  6. 轮训,根据音频当前时间刷新歌词位置,实现即时同步滚动歌词

还有一些细节:

  • WebExtension 项目使用 web-ext 自动刷新,使用 webextension-polyfill-ts 实现跨浏览器支持,使用 Webpack 支持 TS
  • 将歌词排版到画布上需要考虑换行,样式等问题,自己写想必麻烦,最终使用 SVG 将 HTML+CSS 内容导出为 DataURL 渲染到图片上,再到画布上
  • 将当前播放歌曲信息和视频流绑定,避免在 A 封面上显示 B 的歌词

好像能用,速度上线了 WebStore

折腾一:歌曲匹配率太低了

Spotify 上许多华语歌曲的歌手显示繁体,网易云音乐上全是简体,如果要匹配到则必须将繁体转为简体,幸好有 chinese-conv,繁转简之后果然匹配率大大提升。

除了繁体的问题,还有歌名和歌手匹配的问题,比如有合作歌手,在 Spotify 和网易云可能不能完全匹配;歌名带了其他符号,比如合作歌手在 Spotify 中一般会用 "feat." 卸载歌名后面;还有大小写的问题,等等。

最终,我写了个简单的排名算法,把网易云音乐返回歌曲列表一条条算匹配分数,最后取分数最高者。

最近,根据网易云音乐 API 的艺术家搜索接口,将华语歌手的英文名转成了简体中文名,然后再结合上述排名算法了进一步提高了匹配率。最终代码大概这个样子

之所以写成这样的缩进是为了重用变量以减少计算。

尽管如此,有些歌曲网易云音乐有歌词还是不能匹配应用上,歌名歌手字段中各种无规律奇怪的东西,所以还是提供了一个 Popup 界面,供用户手动选择,然后将用户选择匿名记录到 Google Firestore,并通过 Firebase Functions 提供其他用户的读取。

折腾二:Spotify 更新了,WebStore 不能及时更新

有一天收到反馈,扩展不能用了,我回家急忙看了一下,原来是 Spotify 更新了,之前的 CSS 选择器不能用了,但是改了 WebStore 迟迟更新不了,急的我想跑去 WebStore 老家了。

得像个办法实现热更新才行,要是有个配置服务就好了,但又不想用第三方服务,又还想要 TS 类型支持,而且自己写了配置的情况下还要手动写类型是不可能的事,而是我把 json 格式的配置文件放在代码仓库里,然后使用 GitHub 的源代码外链来访问,这样解决了所有问题,代码看起来是这样

折腾三:用户各种需求

比如有的用户说滚动的歌词要是能平滑滚动就好了。干,平滑滚动着吗好做的吗?得自己在 Canvas 上一帧一帧画啊,首先的问题是换行咋做?linebreak 这么复杂的库难道打包进去?Intl.Segmenter API 里面到时有提到换行,但说了暂时不实现,而且这个 API 本来就还没有引擎实现,v8 私有 Intl.v8BreakIterator API 倒是能做,但不兼容 Firefox 啊,看了一眼 polyfill 以及 canvas-text-wrapper 的实现,都是用的正则,而且不支持 CJK 呀。

考虑到最近 Firefox 也支持了 ES 2018 的 Unicode Property Escapes,那么正则换行支持 CJK 不是梦啊,简单的换行规则看起来就像这样

解决了换行,剩下的就是如果让歌词滚动起来,我现在的实现不太完美,大概思路是这样的:

  1. 提前 0.3s 找到当前行
  2. 测量(不实际渲染)前一行的尺寸
  3. 根据前一行的尺寸算当前行的位置、大小和颜色,如果在提前量中就计算各个属性的插值,并绘制当前行。
  4. 在这一行的上面绘制当前时间线之前的其他行
  5. 同理绘制当前时间线后面的行

这样流程在每个 rAF 的回调中都将进行一次,CPU 占用太高。既然大部分时间歌词是静止的,所以我为每次的绘制都保存一个状态,下次绘制时会先比较状态,避免重复的帧绘制以减少 CPU 占用。

还有的用户希望提供更多的选项,比如去掉歌词中的广告、将歌词显示到页面中、自定义字体等等。好嘛,最终不得不提供了一个 Options 页面:

折腾四:用户各种报错

我自己用得好好的啊,但是有的用户说根本不工作,远水救不了近火啊,我不得不统计下用户匹配歌词的情况,收集一下扩展的错误。

经过多次的修改,最终使用了 Google Analytics  的 Measurement Protocol(为什么要用这个呢?因为 Firefox 说了) 进行统计,统计结果看起来像这样

至于错误报告,接入了 Sentry(会不会有有效报告还要看),为了防止 WebExtension 中的各个环境重复打包 Sentry,所以我只在 BackgroundScript 中使用了一个 Sentry 实例,ContentScript 中使用通信将错误发送到 BackgroundScript 在调用 Sentry 的方法进行收集。

由于通信事件种类繁多,为了方便维护和扩展,我将这些事件进行中心化管理:

// 当然,没有收集任何用户的隐私信息,这个统计全部是随机匿名 ID 上报的

其他折腾

Safari 14 听说支持了 WebExtension?感觉尝试起来,果然,直接支持 Chrome 和 Firefox 扩展,但是。。。Safari 的扩展是以 App 附属的方式发行的,也就是说,想要在 AppStore 发行 Safari 扩展,我的首先有个 AppStore 的开发者账号,要上百美金注册也就算了,我账号咋注册不起来?注册到美区还是国区???另外将歌词显示在 Safari 的 PiP 中还有缺陷暂罢

未修复BUG

没错,Spotify Lyrics 还有个已知 BUG 没有修复,就是 PiP 窗口上的播放按钮不起作用(可以暂停),你有兴趣的话可以在这个地址测试。

我认为这是 Chrome 的问题,于是向 Chrome 提交了反馈,静候佳音。

好了,休息一下。最后放一张截图:

_// Spotify Web Player 很优秀,_你值得拥有