實現 LRC歌詞滾動

本文來源:http://fed.renren.com/archives/577#more-577

在開發新版音樂盒時,需要用JS實現歌詞滾動。我在一期開發的基礎上進行了迭代式的開發,又有點類似於敏捷開發。以下根據逐漸完善功能的過程來講述我是如何開發完成了歌詞滾動效果。其中每一步遇到的難點以及錯誤也會逐一列出。

1.解析歌詞

這一步其實很簡單,但由於我沒有很認真的去分析以及預測,導致在測試時發現了一些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 + '<br />';
                     } else {
                            this.timeKey[time] += clause + '<br />';
                     }
              }
       } else {
              if(clause.replace(/\s*/g,'') == '') {
                     continue;
              }
              if(!strArray.length) {
                     time = 0;
                     strArray.push(time);
                     this.timeKey[time] = clause + '<br />';
              }
       }
}
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] = '<p lang="' + marginTop + '">' + tempClause + '</p>';
       } else {
              strArray[i] = '<p lang="0">' + tempClause + '</p>';
       }
       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(/<br \/>/g).length * 25;
}

最後將數組拼接成字符串,填入DOM結構。解析部分就算初步告成。

2.歌詞逐行滾動

由於歌詞部分的功能已經逐漸開始強大,我將這部分單獨出來作爲一個對象,讓它有自己的屬性與方法,方便修改與維護。

我們用一個名爲lrcInterval的interval實時獲取歌曲當前的播放進度,換算成秒數後在JSON中查詢是否有該時間點,有則將頁面歌詞可視區域定位到該句歌詞,並將其標藍。而定位是由給歌詞外層DIV的margin-top屬性賦值實現的。這樣就實現了逐行的滾動方式。HTML結構如下:

<div class="info_container lyric_container" style="margin-top: -350px;">
       <p> 星空<br></p>
       <p> 填詞:五月天阿信<br></p>
       <p> 作曲:五月天石頭<br></p>
       <p> 演唱:五月天<br></p>
       <p> <br></p>
       <p> 摸不到的顏色 是否叫彩虹<br></p>
       <p> 看不到的擁抱 是否叫做微風<br></p>
       <p> 一個人 想着一個人<br></p>
       <p> 是否就叫寂寞<br></p>
       ...
       ...
</div>

這其中的“<br>”換行標籤是爲了防止一些一句歌詞過長或者有些日文歌曲一行日語、一行漢語翻譯導致顯示不全的狀況。例如:

[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 class="info_container lyric_container" style="margin-top: -350px;">
       <p lang="0"> 星空<br></p>
       <p lang="0"> 填詞:五月天阿信<br></p>
       <p lang="0"> 作曲:五月天石頭<br></p>
       <p lang="0"> 演唱:五月天<br></p>
       <p lang="0"> <br></p>
       <p lang="0"> 摸不到的顏色 是否叫彩虹<br></p>
       <p lang="0"> 看不到的擁抱 是否叫做微風<br></p>
       <p lang="25"> 一個人 想着一個人<br></p>
       <p lang="50"> 是否就叫寂寞<br></p>
       ...
       ...
</div>

3.隨進度條拖動而滾動

最後就是要讓歌詞能夠隨着進度條的拖動進行滾動了。在逐行滾動中,歌詞變換時是直接給外層DIV賦予了相應的margin-top,也就是說沒有類似動畫的滑動效果,而是一步到位。這樣的視覺效果欠佳,爲此我們給外層DIV也添加了lang屬性。根據當前外層DIV的lang屬性值和當前歌詞的lang屬性值作對比,判斷是否需要進行滾動。如果需要滾動,可以根據二值之差的絕對值給定歌詞滾動一個初速度(此處的速度並不是標準意義上的速度,而是每次改變外層DIV的margin-top值的δpx),再用一個interval實時改變外層DIV的margin-top值。這樣距離越大,初速度越大,符合用戶拖動滾動條時快速定位的需求。而在外層DIV的margin-top值與歌詞的lang值相差爲一行歌詞的距離時,就讓速度的絕對值爲1px。這樣會有個類似於減速至停止的效果。HTML結構如下:

<div lang="25" style="margin-top: -350px;">
       <p lang="0"> 星空<br></p>
       <p lang="0"> 填詞:五月天阿信<br></p>
       <p lang="0"> 作曲:五月天石頭<br></p>
       <p lang="0"> 演唱:五月天<br></p>
       <p lang="0"> <br></p>
       <p lang="0"> 摸不到的顏色 是否叫彩虹<br></p>
       <p lang="0"> 看不到的擁抱 是否叫做微風<br></p>
       <p lang="25"> 一個人 想着一個人<br></p>
       <p lang="50"> 是否就叫寂寞<br></p>
       ...
       ...
</div>

此時離勝利不遠了。我們發現當拖動進度條至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)
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章