最近在做慕课网上音乐APP项目时碰到的一个小功能,觉得比较有意思,就稍微研究了一下。
1.功能需求
要实现一个在线音乐播放器的歌词功能,需要歌词列表,并且根据音乐播放进度,凸显出当前歌词信息
歌词是从QQ音乐官网的线上接口直接抓取的,Base64格式加密过,解码之后是如下一段Sring
字符串
'[ti:泡沫]\n[ar:邓紫棋]\n[al:Xposed]\n[by:]\n[offset:0]\n[00:00.10]泡沫 - G.E.M. 邓紫棋\n[00:00.20]词:G.E.M. 邓紫棋\n[00:00.30]曲:G.E.M. 邓紫棋\n[00:00.40]\n[00:01.83]阳光下的泡沫 是彩色的\n[00:08.40]就像被骗的我 是幸福的\n[00:15.44]追究什么对错 你的谎言\n[00:22.31]基于你还爱我\n[00:26.05]\n[00:28.61]美丽的泡沫 虽然一刹花火\n[00:35.54]你所有承诺 虽然都太脆弱\n[00:42.40]但爱像泡沫 如果能够看破\n[00:49.45]有什么难过\n[00:52.95]\n[00:57.82]早该知道泡沫 一触就破\n[01:04.18]就像已伤的心 不胜折磨'
// 后边还有很长一部分,格式都一样的,就不都写上了
2.实现
先说说我自己的想法,在没直接引入第三方类库实现前,我自己试着写了这两行代码,具体的实现没有细写,希望先将字符串拆解,然后将信息合理地重组,得到一个可用的JSON格式对象。事后证实,思路还是很正确的(窃喜!!)
var reg = /\[([A-Za-z]+):([^\]\n]*)\]/g //拆分基本信息中的字段
var reg = /\[(\d{2,}):(\d{2})\.(\d{2,})\]([^\]\n]*)/g // 拆分时间和歌词
3. lyric-parser源码解读
最终实现,是引用了黄轶老师开源的一个类库。花了点时间研读了下源码,根据自己的理解,写了注释。
毕竟个人水平有限,理解不对的地方,欢迎指正。
lyric-parser项目地址:https://github.com/ustbhuangyi/lyric-parser
个人认为整个类库最核心的代码,也是最重要的实现,timeExp 和 matchs 中的两句正则
分别用来匹配标签属性 和 歌词
和我自己写的,也算是(不要脸了,那就算是吧)差不多吧!
// '\[' 转义方括号
// '\d{2,}' 0-9任意数字重复至少两次,分别匹配分钟和整数秒
// (?:\.(\d{2,3}))? 小括号里的一坨,也就是毫秒,可能有,也可能没有
// ']' 匹配括号,很奇怪这个括号竟然不需要转义,奇葩!我也是测试后才知道的
const timeExp = /\[(\d{2,}):(\d{2,3})(?:\.(\d{2,3}))?]/g
// 播放状态常量
const STATE_PAUSE = 0
const STATE_PLAYING = 1
// 重新定义了更加语义化的标签名
const tagRegMap = {
title: 'ti',
artist: 'ar',
album: 'al',
offset: 'offset',
by: 'by'
}
function noop() {
}
// 输出 Lyric 类
export default class Lyric {
constructor(lrc, handler = noop) {
this.lrc = lrc
this.tags = {}
this.lines = []
this.handler = handler
this.state = STATE_PAUSE
this.curLine = 0
this._init()
}
_init() {
this._initTag()
this._initLines()
}
_initTag() {
for (let tag in tagRegMap) {
// 匹配出歌词的标签属性
const matchs = this.lrc.match(new RegExp(`\\[${tagRegMap[tag]}:([^\\]]*)`, 'i'))
// 填充进 tags 对象
this.tags[tag] = matchs && matchs[1] || ''
}
}
_initLines() {
// 通过换行符,拆解歌词
const lines = this.lrc.split('\n')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// 匹配时间戳
let result = timeExp.exec(line)
if (result) {
// 去掉时间,只留下文字部分
const txt = line.replace(timeExp, '').trim()
if (txt) {
// 将符合格式的歌词和时间转换后推进列表
this.lines.push({
time: result[1]*60*1000 + result[2]*1000 + (result[3] || 0)*10,
txt
})
}
}
}
// 按歌词的时间顺序重新排了一遍
// 好像本来解析出来顺序就是对的,大概是为了以防万一吧
this.lines.sort((a, b) => {
return a.time - b.time
})
}
// 通过时间,找到当前歌词的位置
_findCurNum(time) {
for (let i = 0; i < this.lines.length; i++) {
if (time <= this.lines[i].time) {
return i
}
}
return this.lines.length - 1
}
// 调用处理函数,并将当前歌词和行数传入
_callHandler(i) {
if (i < 0) {
return
}
this.handler({
txt: this.lines[i].txt,
lineNum: i
})
}
// 持续输出当前的歌词位置
_playRest() {
let line = this.lines[this.curNum]
// 根据歌词中带有的时间信息,计算出下一次歌词变更的时间间隔
let delay = line.time - (+new Date() - this.startStamp)
this.timer = setTimeout(() => {
this._callHandler(this.curNum++)
if (this.curNum < this.lines.length && this.state === STATE_PLAYING) {
// 只要处于播放状态中,重复调用这个函数
this._playRest()
}
}, delay)
}
// 暴露在类库外部调用的功能函数
// 歌词播放
play(startTime = 0, skipLast) {
if (!this.lines.length) {
return
}
// 修改播放状态
this.state = STATE_PLAYING
// 记录下播放开始瞬间的歌词当前值和时间戳
this.curNum = this._findCurNum(startTime)
this.startStamp = +new Date() - startTime
if (!skipLast) {
this._callHandler(this.curNum - 1)
}
if (this.curNum < this.lines.length) {
clearTimeout(this.timer)
this._playRest()
}
}
// 切换播放状态
togglePlay() {
var now = +new Date()
if (this.state === STATE_PLAYING) {
this.stop()
// 记录下暂停的时间戳
this.pauseStamp = now
} else {
this.state = STATE_PLAYING
// 如果是第一次播放,传入 0
// 如果是从暂停处播放,就传入已经播放完的进度时间
this.play((this.pauseStamp || now) - (this.startStamp || now), true)
}
}
// 停止播放
stop() {
this.state = STATE_PAUSE
clearTimeout(this.timer)
}
// 在指定位置开始播放
seek(offset) {
this.play(offset)
}
}