最近发现我们班同学做了一个很酷的Demo,这个Demo实现了一个很不错的歌词同步,着实令我兴奋,迫不及待的想看看其中是怎么实现的,因为其中的效果超过了某些音乐平台的歌词同步了,于是我找他要来了源码,今天我就打算为大家分享这一个Demo,后面我会把源码附在结尾。
要实现歌词同步的话,我们大概可以把他分成几个模块:
歌词与时间加载歌词显示歌曲播放时歌词的滚动鼠标拖拽时歌词的滚动自由滚动歌词以上4点为比较核心的功能了,我们就围绕以上4点来进行讲解其中的实现:
歌词要加载就得有地方加载,所以我们先得定义好布局:
<head> <meta charset="UTF-8"> <title>歌词同步</title> <style type="text/css"> /*同步到的歌词的动画效果,灰变成白*/ /* Chrome, Safari, Opera */ @-webkit-keyframes currLrc { 0% {font-size: 16px;} 100% {color: #fff;font-size: 16px;} } /* Standard syntax */ @keyframes currLrc { 0% {font-size: 16px;} 100% {color: #fff;font-size: 16px;} } /*当前歌词的上一句的动画效果,白变成灰*/ /* Chrome, Safari, Opera */ @-webkit-keyframes lastLrc { 0% {color: #fff;font-size: 12px;} 100% {color: #989898;font-size: 12px;} } /* Standard syntax */ @keyframes lastLrc { 0% {color: #fff;font-size: 12px;} 100% {color: #989898;font-size: 12px;} } /*歌词最大框样式,用于定位整个歌词范围*/ #lrcModel { position: relative; width: 325px; height: 400px; margin: 0; padding: 0; background-color: rgb(33, 36, 41); box-shadow: -3px 8px 135px -40px inset black; } #lrcModel * { margin: 0; padding: 0; } /*歌词的默认样式*/ #lrcModel li { overflow: hidden; padding-top: 5px; padding-bottom: 5px; text-align: center; color: #989898; font-size: 12px; list-style: none; } /*歌词的父容器,与lrcModel大小一致*/ #lrcModel #lrcUlParent { display: inline-block; height: 100%; width: 100%; overflow: hidden; } /*不要修改这里大小,或者边距*/ #lrcModel #lrcList { width: 100%; } /*隐藏的操作面板的样式,不要修改这里大小,或者边距*/ #lrcModel #scrollDiv { vertical-align: top; display: inline-block; width: 100%; /*background-color: red;*/ height: 100%; overflow-y: scroll; position: absolute; left: 0px; top: 0px; opacity: 0.001; /*透明度不能为0,否则鼠标滚轮滚动没效果*/ } /*鼠标经过歌词时鼠标指针变成指向形状*/ #lrcModel #scrollDiv:hover { cursor: pointer; } </style> </head> <body> <!--音频控件--> <audio controls loop="loop" id="audio" style="width: 400px;"> <source src="songs/痒.mp3" type="audio/mp3"></source> </audio> <!-- 歌词框,这里用于显示歌词的布局--> <div id="lrcModel"> <div id="lrcUlParent"> <ul id="lrcList"> <!--歌词放在这里--> </ul> </div> <!--滚动或者拖拽的操作层,铺在ul之上--> <div id="scrollDiv"> <!--用于滚动的盒子--> <div id="openTheBox"> <!-- 歌词填充完之后把这里的高度设置成和歌词ul相同的高度, 用来撑开用来滚动的盒子 --> </div> </div> </div> </body>有了以上布局我们就可以看到如下效果: 可以看到中间的黑色方框就是用来存放歌词的,接下来我们来看看如何把歌词加载到手的: 我们的歌词通常都是lrc格式的,我列出其中一部分:
[ti:我承认我自卑] [ar:杨小壮] [al:我承认我自卑] [by:jiting_karakal] [offset:0] [00:00.00]我承认我自卑 (DJ版) - 杨小壮 [00:00.31]词:杨小壮 [00:00.62]曲:杨小壮 [00:00.93]编曲:张川 [00:01.25]混音:豆豆龙 [00:01.56]制作人:杨栋梁这里我们可以看到每一句歌词都可以对应上一个时间点,而且可以发现时间都是由方括号括起来的,这样就为我们转换为json格式提供了便利,转换成json格式后的部分如下:
"[ti:我承认我自卑]": "", "[ar:杨小壮]": "", "[al:我承认我自卑]": "", "[by:jiting_karakal]": "", "[offset:0]": "", "[00:00.00]": "我承认我自卑 (DJ版) - 杨小壮", "[00:00.31]": "词:杨小壮", "[00:00.62]": "曲:杨小壮", "[00:00.93]": "编曲:张川", "[00:01.25]": "混音:豆豆龙", "[00:01.56]": "制作人:杨栋梁",最后在数组中的样子是这样的:
var lrcJson = { "[ti:我承认我自卑]": "", "[ar:杨小壮]": "", "[al:我承认我自卑]": "", "[by:jiting_karakal]": "", "[offset:0]": "", "[00:00.00]": "我承认我自卑 (DJ版) - 杨小壮", "[00:00.31]": "词:杨小壮", "[00:00.62]": "曲:杨小壮", "[00:00.93]": "编曲:张川", "[00:01.25]": "混音:豆豆龙", "[00:01.56]": "制作人:杨栋梁", "[00:01.87]": "DJ:DJ何鹏", "[00:02.19]": "工作室:创意音工坊", "[00:02.51]": "我承认我自卑我真的很怕黑", "[00:05.69]": "", "[00:06.32]": "每当黑夜来临时候我总是很狼狈", "[00:09.27]": "", "[00:10.71]": "我彻夜在买醉但我不曾后悔", "[00:13.37]": "", "[00:14.32]": "只想让自己清楚为什么我会掉眼泪", "[00:19.06]": "", "[00:20.68]": "一个人的房间", "[00:22.05]": "", "[00:22.74]": "漆黑的夜晚", "[00:24.46]": "", "[00:25.35]": "看着你的故事和你的朋友圈", "[00:29.32]": "", "[00:30.07]": "谁会喜欢孤单", "[00:31.74]": "", "[00:32.34]": "是不想人看穿", "[00:34.10]": "", "[00:34.78]": "最后一点点温暖也被你打散", "[00:38.85]": "", "[00:39.55]": "孤独的人晚上", "[00:41.29]": "", "[00:41.90]": "最害怕有灯光", "[00:43.68]": "", "[00:44.31]": "你关了那灯光心里有一点忧伤", "[00:48.55]": "", "[00:49.14]": "你流泪的眼眶", "[00:50.89]": "", "[00:51.51]": "他走后的模样", "[00:53.29]": "", "[00:53.96]": "深深刺痛的心告诉自己要坚强", "[00:58.02]": "", "[01:01.27]": "我承认我自卑我真的很怕黑", "[01:05.27]": "", "[01:05.84]": "黑夜来临时候我总是很狼狈", "[01:10.06]": "", "[01:10.69]": "我彻夜在买醉但我不曾后悔", "[01:14.93]": "", "[01:15.49]": "只想让自己清楚为什么掉眼泪", "[01:19.65]": "", "[01:20.26]": "这孤单的滋味我慢慢的体会", "[01:24.44]": "", "[01:25.15]": "时间会让我遇见我心中的玫瑰", "[01:29.25]": "", "[01:29.90]": "要等到时间对等大雁向南飞", "[01:34.14]": "", "[01:34.67]": "我暖着我的玫瑰不让她枯萎", "[01:38.58]": "", "[01:58.94]": "孤独的人晚上", "[02:00.43]": "", "[02:01.06]": "最害怕有灯光", "[02:02.88]": "", "[02:03.47]": "你关了那灯光心里却有一点忧伤", "[02:08.28]": "你流泪的眼眶", "[02:10.08]": "", "[02:10.68]": "他走后的模样", "[02:12.45]": "", "[02:13.06]": "深深刺痛的心告诉自己要坚强", "[02:17.26]": "", "[02:17.92]": "我承认我自卑我真的很怕黑", "[02:22.08]": "", "[02:22.68]": "黑夜来临的时候我总是很狼狈", "[02:26.83]": "", "[02:27.49]": "我彻夜在买醉但我不曾后悔", "[02:31.66]": "", "[02:32.27]": "只想让自己清楚为什么掉眼泪", "[02:36.43]": "", "[02:37.05]": "这孤单的滋味我慢慢的体会", "[02:41.28]": "", "[02:41.86]": "时间会让我遇见我心中的玫瑰", "[02:46.03]": "", "[02:46.65]": "要等到时间对等大雁向南飞", "[02:50.86]": "", "[02:51.48]": "我暖着我的玫瑰不让她枯萎", "[02:55.65]": "", "[02:56.41]": "要等到时间对等大雁向南飞", "[03:00.53]": "", "[03:01.07]": "我暖着我的玫瑰不让她枯萎" };我们可以这样取出歌词:lrcJson["[00:00.00]"]得到的结果为:我承认我自卑 (DJ版) - 杨小壮 只要我们把歌词数据存到了数组中,后面将歌词呈现出来就有规律可循了。 歌词加载完成了,现在还剩下时间了,时间我们用数组存起来,如何存合理呢,我们来看看下面的处理过程:数组的键分为时分秒,由于从audio控件获取的当前时间为纯秒数,所以我们要将时分秒转格式转换为单纯的浮点数: 我们可以将分提取出来乘以60得到秒,再加上后面的秒就可以了:
parseFloat(分钟) * 60 + parseFloat(秒)在网页中我们可以将每一句歌词显示在每一个li中,处理起来也会方便很多,我们可以把这个操作与前面的歌词和时间初始化写成方法:
function resetLyrics(lrcJSON) { // 先清空所有歌词列 $ul.html(""); var i = 0; //遍历lrcJSON数组的每一个key(时间):value(歌词) $.each(lrcJSON, function(key, value) { // 将没有歌词的键值对过滤掉,只有带歌词和时间的键值对才有用 if(value.trim().length > 0) { //截取分*60+截取秒转换成秒格式,可以通过在后面加或减来设置同步延迟 lrcTime[i++] = parseFloat(key.substr(1, 3)) * 60 + parseFloat(key.substring(4, 10)) - 0.1; //往ul里填充歌词 $ul.append($("<li>" + value + "</li>")); } }); //由于当前一句歌词要靠下一句歌词的播放时间计算,所以要让最后一句歌词同步的话必须要在最后一句歌词时间数组后面加上一个时间 lrcTime[lrcTime.length] = lrcTime[lrcTime.length - 1] + 3; //获取所有li $li = $("#lrcModel li"); // 获取单个歌词节点的高度,获取的值会比真实值大0.2,所以-0.2 liHeight = $li.outerHeight() - 0.2; // 歌词加载完毕之后撑开右边用于滚动的框 $("#lrcModel #openTheBox").height($ul.height()); // 计算能滚动的最大位置 +10是歌词最下方留的空隙 maxTop = ($ul.height() - $ulParent.height())+10; currentLine = 0;//将当前播放的歌词行数设为0 // 刷新歌词时刷新 ul的位置 $ul.css({"transform": "translateY(0px)","transition-duration": "500ms"}); }这样通过这个方法可以把歌词显示在歌词框中了: 看歌词显示出来了。
歌词显示出来了,但是歌曲播放时歌词并不会随之滚动和改变相应样式大小,想要让歌词完美的滚动起来,要从多个角度和因素去思考: 1.所有歌词行加起来的高度与歌词框高度的关系 2. 如何判断播放器时间对应上每句歌词的时间使其同步 3. 歌词的滚动细节计算
这要谈到第一点了,要计算所有歌词行加起来的高度与歌词框高度的关系,正常情况下歌词的总高度肯定要比歌词框,所以我们将歌词总长度减去歌词框高度,就可以得到歌词允许滚动的范围:即对应了最后一句歌词刚好在歌词框的底部,比如歌词总高度为1200px,歌词框高度400px,1200 - 400 = 800px 那么这个800px就是歌词第一句歌词所在的高度,但是这个高度为 -800px,也可以看出来-800px的高度刚刚好对应了最后一句歌词刚好在歌词框的底部。 在得到了这个之后,我们就可以很直观的容易的控制歌词向上滚动,我们只要拿着这个800px减去一行歌词或者多行歌词的高度,就可以控制控制歌词的上下位置了。
歌词不会自己跑,还是需要通过歌曲播放的进度的改变而改变,这就要想方设法让歌词的滚动与播放器进度同步,播放器提供了ontimeupdate事件,这个事件会在播放时的每一刻触发,我们可以利用这个事件触发让每一句歌词完美无缝的对应上每一句音频。 如何判断呢,我们之前有把每一句歌词对应的时间点存储在数组中,那么我们可以获取播放器每一时刻的秒数,来对比数组中的时间,检查是否可以同步到当前歌词。
先来看看歌词滚动的核心代码:
audioNode.ontimeupdate = function() { // 该循环的主要作用是控制当前歌词行号和歌词的播放时间 for(var j = currentLine; j < lrcTime.length; j++) { // 若当前歌词时间小于下句歌词时间,就说明还没播放到下一句歌词 if(audioNode.currentTime < lrcTime[j + 1]) { // 改变播放到的行数 currentLine = j; // 判断是否为同一行,如果不是那就允许同步 if(LastLine !== currentLine) { //获得当前歌词以及当前歌词之前的所有歌词的高度之和 var currHeight = currentLine * liHeight; //为了解决歌词样式变化时间>单句歌词持续时间而做的判断 var time = (lrcTime[currentLine + 1] - lrcTime[currentLine]) > (changeTime / 1000) ? changeTime : (lrcTime[j + 1] - lrcTime[j]) * 1000; // 将当前播放同步的歌词高亮 $($li[currentLine]).css("animation", "currLrc " + ((time) / 1000) + "s linear 0s 1 alternate forwards"); // 将上一句歌词还原 $($li[currentLine - 1]).css("animation", "lastLrc " + ((time) / 1000) + "s linear 0s 1 alternate forwards"); // 当前播放歌词的总高度减去歌词框高度的一半 top = (currHeight- scrollDivHeight * LrcPosition); //小于0说明播放过的歌词还未过半,不滚动 if(top < 0) { //让歌词呆在原地先不动 top = 0; //如果大于了最大允许滚动范围,则加以限制 } else if(top > maxTop) { //防止播放完毕时继续滚动 top = maxTop; } //这个是为了在用户滚动之后的三秒内,歌词自己不动 if(mousewheelFlag) { //改变滚动条高度为当前播放位置 $scrollDiv[0].scrollTop = top; // 改变 ul 的上下位置,滚动的核心代码 $ul.css({ "transform": "translateY(-" + (top) + "px)", "transition-duration": "" + (time) + "ms" }); } //记录当前播放行数 LastLine = currentLine; } break; } } }上面方法中的循环尤为重要,但并不是想象中的那样循环,他的作用主要有两个,一个是控制当前播放行数,一个是控制当前播放时间,每次执行这个循环的循环次数不超过两次。 这个循环的主要功劳代码如下:
var j = currentLine; j < lrcTime.length; j++ //第3行 currentLine = j; //第7行 LastLine = currentLine; //第40行 break; //第42行有时自动播放太慢,用户想要拖动进度条到精彩的地方听时就会发生问题,歌词不会实时同步进度条,那么我们可以用类似于上面自动同步的逻辑来编写下面的拖拽时歌词的滚动:
audioNode.onseeked = function() { //重置所有歌词li的样式 $li.each(function() { $(this)[0].removeAttribute("style") }); var Top2; // 从0 开始遍历时间数组 for(var k = 0; k < lrcTime.length; k++) { // 如果当前的播放时间小于上一句并且大于下一句句,那么K就是当前行 if(audioNode.currentTime > lrcTime[k - 1] && audioNode.currentTime < lrcTime[k + 1]) { currentLine = k; Top2 = currentLine * liHeight - scrollDivHeight + (scrollDivHeight / 2); // 计算这个值是为了歌曲播放进度被改变的时候立即调整 歌词ul 的上下位置 if(Top2 > maxTop) { // 不能超过能修改的最大位置 Top2 = maxTop; } $($li[currentLine - 1]).css("animation", "currLrc 0s linear 0s 1 alternate forwards"); // 当前句的样式 $ul.css({ "transform": "translateY(-" + Top2 + "px)", "transition-duration": "0ms" }); LastCurrentLine = currentLine - 1; return; } } // 如果改变播放进度的时候 所有行都没有匹配到,那么就是走到最后一行了 $($li[lrcTime.length - 2]).css("animation", "currLrc 0s ease-in-out 0s 1 alternate forwards"); // 最后一句的样式 -2是因为歌词时间数组的最后一个值是额外添加的,没有对应的歌词节点 $ul.css({ "transform": "translateY(-" + maxTop + "px)", "transition-duration": "0ms" }); // 修改 歌词ul的位置 $scrollDiv[0].scrollTop = maxTop; // 修改滚动条的位置 LastCurrentLine = currentLine - 1; }不同的是循环和最后一句歌词的处理,都大同小异。
其实有了以上的功能基本上一个简单的歌词同步就可以做好了,但是有一些场景还是需要处理的,比如用户想要在听歌的时候拖动歌词看看前面的歌词怎么办,这时我们要想办法把当前的自动同步停下来,让歌词随着用户的拖动而滚动,上面的同步例子中就有这么一行代码:
//用户滚动之后的三秒内,歌词自己不动 if(mousewheelFlag)现在我们要做的就是在用户用鼠标滚轮滚动歌词时把mousewheelFlag设为false就可以了,现在我们先为鼠标滚轮写好事件监听:
function addMouseWheel(obj, fn, preventDefault) { //检查浏览器 if(window.navigator.userAgent.toLowerCase().indexOf("firefox") != -1) { //火狐 obj.addEventListener("DOMMouseScroll", fnWheel, false); } else { //其他 obj.onmousewheel = fnWheel; } //处理方法 function fnWheel(ev) { var oEvent = ev || event;//为了兼容 var bDown = true; //下 if(oEvent.wheelDelta) { //ie chrome为了兼容 bDown = oEvent.wheelDelta > 0 ? false : true;//大于0向上否则向下 } else { //ff bDown = oEvent.detail > 0 ? true : false;//大于0向下否则向上 } (typeof fn == "function") && fn(bDown);//fn如果是function那就调用fn if(preventDefault) {//如果不为假 oEvent.preventDefault && oEvent.preventDefault();//去除默认操作 return false; } } }以上代码核心还是要调用fn方法达到监听效果:
/** * 添加鼠标滚轮 滚动事件监听 */ addMouseWheel($scrollDiv[0], function() { //滚动就把mousewheelFlag设为false mousewheelFlag = false; //清除计时器 clearTimeout(mousewheelTimeoutFlag); //添加计时器用于3秒后把mousewheelFlag设为true mousewheelTimeoutFlag = setTimeout(function() { mousewheelFlag = true; }, 3000); });然后就剩下要滚动时的效果了:
/** * 被滚动的时候修改 ul 的上下位置 */ $scrollDiv.scroll(function() { //只有在触发滚轮滚动时才会执行 if(!mousewheelFlag) { //滚动的距离按照滚动的scrollTop来控制 $ul.css({ "transform": "translateY(-" + (this.scrollTop) + "px)", "transition-duration": "0ms" }); } });到此一个基本的歌词同步就完成了,看代码的时候一定要记得多调试,观察每一个变量的值,把握好每一个变量。 效果图:
最后再附上一个解析lrc文件的源码:
/** * 获得歌词 * @param path 歌词绝对路径 * @return 拼接的JSON 格式歌词字符串 */ public static String getLrc(String path){ FileReader fs = null; BufferedReader br = null; StringBuffer sbf = new StringBuffer(); try { fs = new FileReader(path); br = new BufferedReader(fs); int i = 0; String sb = ""; sbf.append("{"); while((sb = br.readLine()) != null){ i++; if (i>=5) { int bb = sb.lastIndexOf("]"); sbf.append("\""+sb.substring(0,bb+1)+"\""+" : "+"\""+sb.substring(bb+1)+"\""+","); } } sbf.delete(sbf.length()-1,sbf.length()); sbf.append("}"); System.out.println(sbf); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }finally{ if (br != null) { try { br.close(); } catch (IOException e) { e.printStackTrace(); } } if (fs != null) { try { fs.close(); } catch (IOException e) { e.printStackTrace(); } } } return sbf.toString(); }最后希望大家都有开源的精神,每天都有新的收获。