本文来源:http://fed.renren.com/archives/577#more-577
在开发新版音乐盒时,需要用JS实现歌词滚动。我在一期开发的基础上进行了迭代式的开发,又有点类似于敏捷开发。以下根据逐渐完善功能的过程来讲述我是如何开发完成了歌词滚动效果。其中每一步遇到的难点以及错误也会逐一列出。
这一步其实很简单,但由于我没有很认真的去分析以及预测,导致在测试时发现了一些Bug,甚至是后果很严重的Bug。比如:
一开始我只是简单地看了几首歌曲,没有做大量的数据分析,虽然快速地将歌词进行了解析,正则表达式如下:
/\[\d*:\d*\]/g
这样只能匹配格式为“[00:00]”这样的时间戳,后来发现还有格式为“[00:00.00]”或“[00:00:00]”。我们罗列出LRC的所有普遍格式:
[00:00]
[00:00.00]
[00:00:00]
然后就能够写出全面匹配出时间戳的正则表达式了,如下:
/\[\d*:\d*((\.|\:)\d*)*\]/g
但是更严重的问题来了,在解析一首韩文歌曲时,程序挂了。而此时也无法准确定位错误。在一点点排除错误代码之后,发现是在解析后台返回的字符串时,我让该字符串直接进行了正则匹配,由于这首歌的LRC歌词太长,导致程序长时间无响应。
接下来就是思考怎么将原歌词进行截断,然后逐句进行解析。我们观察到每一句带时间戳的歌词后都有一个换行符即“/n”,如下(为了方便理解,特将转义之前的换行符标出):
[02:56.80]摸不到的颜色 是否叫彩虹/n
[03:02.76]看不到的拥抱 是否叫做微风/n
[03:08.78]一个人 习惯一个人/n
[03:14.88]这一刻独自望着星空/n
[03:20.83]从前的从前从没变过/n
[03:26.63]寂寞可以是忍受 也可以是享受/n
于是第一步先将后台返回的LRC根据“/n”分割存入数组。
lrcArray = lrc.split(‘\n’);
这样解析时不会再有以上Bug了。但要解析出正确的歌词,还需要将数据进行decode了。
decodeURIComponent(lrcArray[i]).replace(/\[\d*:\d*((\.|\:)\d*)*\]/g,’ ‘);
现在可以输出正确的解析结果了。接下来,该把时间戳取出存储起来,并将歌词再拼接成字符串,然后填进展示歌词的DOM结构里。一开始我是将数组中每个元素里的时间戳去掉,然后直接再把数组拼接成字符串。可是问题随之又来了。形如下面的歌词根据以上方法就产生了不完整的歌词。
[02:37.83][01:09.56]仿佛能看见明日两串脚印的走廊
[02:42.28][01:14.19]忧伤有时候竟被你调味得像颗糖
[02:46.75][01:18.68]是你抓紧我 往前去张望
[02:51.08][01:23.05]望我内心夹岸群花盛放
[02:55.03][01:27.17]我被写在你的眼睛里眨呀
[02:59.40][01:31.64]你被写在我的歌里连成调
[03:03.73][01:36.09]我们被写在彼此心里
很多歌曲的副歌部分歌词几乎是完全重复的,LRC制作者便会将重复的时间戳标注到相同歌词上。 所以我们需要先将时间戳全部取出来保存,然后按照时间顺序重新排列每一句,最后再拼接歌词。至此我用了两个数据结构——一个JSON用来存储时间戳,这是为了后期做滚动歌词时能够根据时间点快速找出时间戳,然后定位歌词。另一个就是数组用来临时存储歌词,以便后面合成页面展示的字符串。
于是我们可以利用JSON能够快速查询对象以及数组原生的排序方法来重新整理出完整的歌词了。为此进行了两遍循环:
第一遍循环,JSON存储歌词,即时间点(秒数)和对应歌词的键值对。数组用于存储时间点。利用数组的.sort()方法将时间点重新排序。
for(var i = 0,l = lrcArray.length;i < l;i++){
//正则匹配 删除[00:00.00]格式或者 [00:00:00]格式
//所有的 lrc 都应该 decode 一下,因为各种语言都可能有
clause = decodeURIComponent(lrcArray[i]).replace(/\[\d*:\d*((\.|\:)\d*)*\]/g,' ');
timeRegExpArr = decodeURIComponent(lrcArray[i]).match(/\[\d*:\d*((\.|\:)\d*)*\]/g);
if(timeRegExpArr) {
for(var k = 0,h = timeRegExpArr.length;k < h;k++) { //第一遍循环,JSON存储歌词,数组存储时间
min = Number(String(timeRegExpArr[k].match(/\[\d*/i)).slice(1));
sec = Number(String(timeRegExpArr[k].match(/\:\d*/i)).slice(1));
time = min * 60 + sec;
if(!this.timeKey[time]) {
strArray.push(time);
this.timeKey[time] = clause + '
';
} else {
this.timeKey[time] += clause + '
';
}
}
} else {
if(clause.replace(/\s*/g,'') == '') {
continue;
}
if(!strArray.length) {
time = 0;
strArray.push(time);
this.timeKey[time] = clause + '
';
}
}
}
strArray.sort(function(a,b) {
return a - b;
});
第二遍循环,先将数组替换成歌词,JSON存储对应歌词行号(此处与编程中的数组下标相对应,从0开始),也就是变成了时间点和行号的键值对。
for(var i = 0,l = strArray.length;i < l;i++) { //第二遍循环,JSON存储时间,数组存储歌词
var tempIndex = strArray[i],
tempClause = this.timeKey[tempIndex];
if(marginTop > 0) {
strArray[i] = '' + tempClause + '
';
} else {
strArray[i] = '' + tempClause + '
';
}
if(i) {
for(var k = lastSec;k < tempIndex;k++) { //将之前空余时间全赋值,以便拖动时定位
this.timeKey[k] = i - 1;
}
} else {
for(var k = lastSec;k < tempIndex;k++) { //将最开始未标记的时间的键值定为负值
this.timeKey[k] = -1;
}
}
this.timeKey[tempIndex] = i;
lastSec = tempIndex + 1;
marginTop += String(tempClause).match(/
/g).length * 25;
}
最后将数组拼接成字符串,填入DOM结构。解析部分就算初步告成。
由于歌词部分的功能已经逐渐开始强大,我将这部分单独出来作为一个对象,让它有自己的属性与方法,方便修改与维护。
我们用一个名为lrcInterval的interval实时获取歌曲当前的播放进度,换算成秒数后在JSON中查询是否有该时间点,有则将页面歌词可视区域定位到该句歌词,并将其标蓝。而定位是由给歌词外层DIV的margin-top属性赋值实现的。这样就实现了逐行的滚动方式。HTML结构如下:
星空
填词:五月天阿信
作曲:五月天石头
演唱:五月天
摸不到的颜色 是否叫彩虹
看不到的拥抱 是否叫做微风
一个人 想着一个人
是否就叫寂寞
......
这其中的“
”换行标签是为了防止一些一句歌词过长或者有些日文歌曲一行日语、一行汉语翻译导致显示不全的状况。例如:
[00:23.00]眩しくて逃げた いつだって弱くて
[00:23.50](因为太过於炫丽而想逃开 不知何时变的如此的软弱)
[00:30.00]あの日から 変わらずいつまでも変わらずに
[00:30.50](从那一天开始 没有改变的始终还是没有改变)
[00:39.45]いられなかったこと 悔しくて指を离す
[00:39.95](从来没有拥有过的事物 只能懊悔的让它从指尖中离去)
由于我们是精确到秒级别的,所以在相同时间点上会有两句歌词,就需要换行显示,而在同一个p标签里,可以在滚动时同时将其标蓝。
然后着重说明一下给margin-top赋值时的改进。一开始是根据当前时间点找到对应歌词行号,然后根据计算,实时赋值。考虑到后期要做拖动进度条跟随滚动的效果,必须记录下每句歌词展示时的margin-top的值,参考了一些同类产品后,我采取了百度听的策略,在解析歌词进行第二遍循环时,就将该句歌词定位的margin-top赋值给这句歌词的DOM元素的lang属性。这样就能够实时获取,而不用做大量运算。HTML结构改进如下:
星空
填词:五月天阿信
作曲:五月天石头
演唱:五月天
摸不到的颜色 是否叫彩虹
看不到的拥抱 是否叫做微风
一个人 想着一个人
是否就叫寂寞
......
最后就是要让歌词能够随着进度条的拖动进行滚动了。在逐行滚动中,歌词变换时是直接给外层DIV赋予了相应的margin-top,也就是说没有类似动画的滑动效果,而是一步到位。这样的视觉效果欠佳,为此我们给外层DIV也添加了lang属性。根据当前外层DIV的lang属性值和当前歌词的lang属性值作对比,判断是否需要进行滚动。如果需要滚动,可以根据二值之差的绝对值给定歌词滚动一个初速度(此处的速度并不是标准意义上的速度,而是每次改变外层DIV的margin-top值的δpx),再用一个interval实时改变外层DIV的margin-top值。这样距离越大,初速度越大,符合用户拖动滚动条时快速定位的需求。而在外层DIV的margin-top值与歌词的lang值相差为一行歌词的距离时,就让速度的绝对值为1px。这样会有个类似于减速至停止的效果。HTML结构如下:
星空
填词:五月天阿信
作曲:五月天石头
演唱:五月天
摸不到的颜色 是否叫彩虹
看不到的拥抱 是否叫做微风
一个人 想着一个人
是否就叫寂寞
......
此时离胜利不远了。我们发现当拖动进度条至LRC中未标记的歌词时,歌词无法滚动,这是因为JSON中并未存储这些时间点。其实每一秒都应该有与之对应的LRC歌词,只要在解析歌词的第二遍循环时将这些时间点都存入JSON中,并赋值相应的歌词行号就可以了。值得注意的是在LRC第一个时间戳之前的所有时间点歌词是没有任何滚动以及标蓝的,将这些时间点进行特殊标记在滚动歌词时加以判断就好了。
设置interval实时获取当前播放进度:
var lrcInterval = setInterval(function() {
var curLrcNode = T.timeKey[Math.floor(player.getPosition() / 1000)];
if(curLrcNode > -1) {
var lrcMarginTop = 0 - Number(T.lrcNodes[curLrcNode].lang),
v = Math.floor((T.lrcNodes[curLrcNode].lang - T.lrcNodes[T.prevLrcNode].lang) / 25);
if(v != 0) {
T.moveLrc(v , lrcMarginTop);
}
T.lrcNodes[T.prevLrcNode].style.color = '#666666';
T.lrcNodes[curLrcNode].style.color = T.lrcColor;
T.prevLrcNode = curLrcNode;
} else {
var v = Math.floor((0 - T.lrcNodes[T.prevLrcNode].lang) / 25);
if(v != 0) {
T.moveLrc(v , 0);
}
T.lrcNodes[T.prevLrcNode].style.color = '#666666';
T.prevLrcNode = 0;
}
},1000);
歌词滑动函数:
moveLrc: function(v,d) { //param:运动速度v , 终点 d
var lrcContent = this.lrcContent;
if(this.moveInterval) {
window.clearInterval(this.moveInterval);
}
this.moveInterval = setInterval(function() {
var top = parseInt($(lrcContent).getStyle('marginTop'));
if(Math.abs(top - d) <= 25) { //当绝对距离小于行高时,速度减为1px每单位时。
v = v / Math.abs(v);
}
if(v > 0) { //速度为正时,歌词向上滚动
if(top > d) {
lrcContent.style.marginTop = top - v + 'px';
} else {
if(top == d) {
window.clearInterval(this.moveInterval);
lrcContent.lang = d;
} else {
v = 0 - v;
}
}
} else { //速度为正时,歌词向下滚动
if(top < d) {
lrcContent.style.marginTop = top - v + 'px';
} else {
if(top == d) {
window.clearInterval(this.moveInterval);
lrcContent.lang = d;
} else {
v = 0 - v;
}
}
}
},15)
}