马黑黑 发表于 2024-2-3 08:27

range进度条播放器+LRC歌词同步教程(三)

<style>
.mum { font-size: 18px; }
.mum pre { padding: 12px; background: #eee; color: navy; font: normal 16px/20px Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; tab-size: 4; white-space: pre-wrap; word-wrap: break-word; }
.mum a { color: red; }
.mum a:hover { color: darkred; }
.tGreen { color: green; }
.tRed { color: red; }
</style>

<div class="mum">

<p>上一讲,<a href="https://www.huachaowang.com/forum.php?mod=viewthread&tid=73988&extra=page%3D1" target="_blank">《range进度条播放器+LRC歌词同步教程(二)》</a>,我们探讨了关键帧动画的反复运行,以双动画来回替换的形式驱动歌词模拟lrc同步,但还没有加入歌词,本节就解决歌词问题、以及歌词如何真正模拟lrc同步。</p>
<p>我创建的花潮格式lrc歌词数组是一个二维数组,结构如下:</p>
<pre>
geci = [
        ,
        ,
       
];
</pre>
<p>各行歌词自身就是一个数组,它有三个数组元素:第一个是数据(number)格式,记录当句歌词起唱时间(秒);第二个是字符串(string)格式,用引号包裹起来,是要显示的歌词;第三个是数据(number)格式,记录的是当句歌词用时(秒)。这些,它们是子维数组元素,是子数组,它们整体放在一个中括号 [] 内,外围中括号是父维数组。geci,是歌词数组变量名,我们要读取第一行歌词信息,就通过 geci 来操作,geci 将会得到 ,,是一个数组。数组通过下标读取数字子项目的内容,下标从 0 起算,0 小标对应第一个数组元素,1对应第二个,依此类推。看下面的读取举例:</p>
<pre>
let ar1 = geci; <span class="tGreen">/* ar1 会得到一个数组格式的数据 → , */</span>
let gc1 = ar1; <span class="tGreen">/* gc1 会得到一个字符串 → 歌词1 */</span>

<span class="tGreen">/* 可以一次性读取第N句歌词(N是一个具体数字) */</span>
let gc2 = geci; <span class="tGreen">/* gc2 的值 → 歌词1 */</span>
</pre>
<p>对二维数组的读取,第一个下标 读到第N个子数组,第二个下标 的子数组的下标,指向子数组的第 2 个元素。</p>
<p>弄懂了读取歌词机制,下来就是如何让歌词显示出来并模拟lrc同步。我们应该还有印象,audio音频控件的timeupdate事件会返回播放器的 currentTime 信息,currentTime 是音频播放器当前的播放位置(以秒计),我们就在这里做文章:在 audio 的 timeupdate 事件中,将 currentTime 和 歌词数组记录的每一句歌词的起唱时间进行比对,符合我们设定的条件就把歌词显示出来。条件怎么设定?比如现在timeupdate事件返回的currentTime为2.5秒,geci数组里头,第一句的起唱时间是2秒、说明现在正在唱“歌词1”,即将唱“歌词2”,歌词2起唱时间是6秒,待timeupdate事件返回大于等于6秒的恰那,我们就处理相关事宜让lrc元素显示歌词2、执行关键帧动画。请看代码样板来加以理解:</p>
<pre>
<span class="tGreen">/* audio timeupdate 监听事件 */</span>
aud.addListener('timeupdate', () => {
        <span class="tGreen">/* ... 这里是进度驱动等代码 */</span>
        <span class="tRed">for (let j = 0; j < geci.length; j ++) {
                if (aud.currentTime &gt;= geci) {
                        <span class="tGreen">/* ... 这里的代码将实现歌词显示机制 */</span>
                }
        }</span>
});
</pre>
<p>for循环放在timeupdate监听事件里,这意味着audio每返回一次 currentTime,我们都要循环一次歌词数组,然后,如上已述,我们在这里要比对 currentTime 和 geci,如果 currentTime 大于等于数组下标为 j 的子数组的起唱时间记录,就处理歌词显示及执行动画等工作。处理机制我们写成一个 showLrc(time) 函数,time 是个传参,将有 timeupdate 监听等事件传给:</p>
<pre>
<span class="tGreen">/* 声明一个全局变量 lrcKey : 用来记录处理的歌词序号 */</span>
var lrcKey = 0;

<span class="tGreen">/* 显示lrc歌词函数 */</span>
var showLrc = (time) => {
        lrc.textContent = lrc.dataset.lrc = geci.replace(/&lt;br&gt;/, '\n'); <span class="tRed">/* ① */</span>
        lrc.style.setProperty('--ani', ['lrcGo0','lrcGo1']); <span class="tRed">/* ② */</span>
        lrc.style.setProperty('--duration', time + 's'); <span class="tRed">/* ③ */</span>
        papa.style.setProperty('--state', 'running'); <span class="tRed">/* ④ */</span>
        aniIdx = aniIdx === 0 ? 1 : 0; <span class="tRed">/* ⑤ */</span>
        lrcKey ++; <span class="tRed">/* ⑥ */</span>
};
</pre>
<p>函数的第 ① 行,令 lrc 的文本(lrc.textContent)和标签携带的 data-lrc 属性值都等于 geci 数组第 lrcKey 子项的第二子项 <span class="tRed">geci</span> 即歌词字符串,对歌词还做了替换处理,&lt;br&gt; 替换为换行符,这是兼顾双语歌词用的,花潮格式的歌词数组约定,双语歌词用&lt;br&gt;标签隔开双语歌词,而这里使用文本性质赋值给lrc标签,所以有必要转换一下(歌词中没有&lt;br&gt;标签不影响赋值语句的执行)。</p>
<p>函数的第 ②、③、④ 行,用 setProperty 改变几个针对lrc元素和papa元素的CSS变量;① --ani 动画名称,它依据 aniIdx 变量值从数组 ['lrcGo0','lrcGo1'] 获得应执行的动画名;② --duration 歌词动画运行时长,它依据函数的唯一传参(传递过来的参数)time 赋值,time 值则是在 audio 控件的 timeupdate 监听事件中调用本函数时传来的,CSS事件变量要加上单位 s 即秒;③ 前两个针对lrc元素,最后一个针对papa元素,--state 关键帧动画运行状态,这里赋的值是 running —— 要模拟lrc歌词,动画自然要运行。</p>
<p>第 ⑤ 行,处理 aniIdx 变量,该变量是变换动画名称的依据,两次间的运行不同相同,但它的值非0即1,所以用一句三元运算简洁地解决问题:当前 aniIdx 若等于 0,则它现在等于 1,否则它现在等于 0,如此就能保证两句之间运行的动画不会重复,CSS动画的反复调用就没有障碍,lrc歌词模拟才可能实现。</p>
<p>最后,第 ⑥ 行,lrcKey 自增,数值变量名后面来个 ++ 等同于 数值 = 数值 + 1,用 ++ 显得更简洁。这一句至关重要,是歌词序列往后推进所在,也是 timeupdate 监听事件正常工作的依托:for循环语句里,先比对时间,时间相符后再比对当前的 lrcKey 值是否也相符,如果也相符就执行本函数,本函数完成相应工作后,令 lrcKey 加 1 ,timeupdate 监听事件在下一句歌词开唱前for循环中就不会重复比对 j 和 lrcKey 的值(重复比对就不能推进歌词序列,就像原地踏步走一样)。</p>
<p>根据 lrcKey 变量和以上 showLrc(time) 函数,timeupdate监听事件此时需要加入另一个条件判断语句,currentTime 和 歌词起唱时间匹配后,将循环步进 j 和 lrcKey 变量进行比对,下面是前述 timeupdate 代码样板的改进:</p>
<pre>
<span class="tGreen">/* audio timeupdate 监听事件 */</span>
aud.addListener('timeupdate', () => {
        <span class="tGreen">/* ... 这里是进度驱动等代码 */</span>
        <span class="tRed">for (let j = 0; j < geci.length; j ++) {
                if (aud.currentTime &gt;= geci) {<span class="tGreen">/* 第一个 if */</span>
                        if (j === lrcKey) showLrc(geci); <span class="tGreen">/* 第二个 if */</span>
                }
        }</span>
});
</pre>
<p>第一个 if 语句是第一个前提,如果播放器的currentTime和歌词的起唱时间信息比对成功,则运行嵌套在里面的第二个 if 语句,第二个 if 语句拿 j 和 lrcKey 比对,若等于就运行 showLrc(time) 函数,一旦运行 lrcKey 就变为了 lrcKey + 1,这样,当句正在播放时的余下 for 循环 j 和 lrcKey 匹配不上,就不会再操作歌词显示机制,直到第一个前提再次成立、要播放唱下一句歌词,如此反复。</p>
<p>lrck加1的机制解决了新的问题,同时也创造一个新的问题:一路加下去的话 lrcKey 值会大于歌词数组总数,所以我们要处理它。实际上,lrcKey 是否大于歌词总数我们都要处理的,考虑一下:我们的播放器是可以手动调整进度的,当用户调整了进度,歌词序号总得重新计算。进度调整会触发 audio 的 onseeked 事件,包括循环播放机制的重新播放也会触发此事件,所以本节,只需在 seeked 监听事件中令 lrcKey 等于 0 即可。</p>
<p>下面,是时候将 range 播放器代码的时机了,以下代码,修改过的或新的代码会用红色标记,和歌词相关的地方会有简要的注释说明:</p>
<pre>
<span class="tGreen">&lt;!--***** CSS代码 *****--&gt;</span>
&lt;style&gt;
        #papa { margin: auto; width: 800px; height: 360px; background: linear-gradient(tan,gray); box-shadow: 3px 3px 20px #000; position: relative; display: grid; place-items: center; }
        #lrc { position: absolute; font: bold 2.4em sans-serif; color: lightblue; text-shadow: 1px 1px 1px rgba(0,0,0,.45); }
        #lrc::before { position: absolute; content: attr(data-lrc); width: 100%; height: 100%; color: transparent; background: linear-gradient(rgba(250,0,0,.7),rgba(0,0,180,.8)); background-clip: text; -webkit-background-clip: text; clip-path: inset(0 100% 0 0); animation: var(--ani) <span class="tRed">var(--duration)</span> linear forwards <span class="tRed">var(--state)</span>; border-bottom: 1px solid navy; }
        #mplayer { position: absolute;<span class="tRed">bottom: 20px;</span> text-align: center; }
        #mplayer::before { position: absolute; content: attr(data-tt); left: 0; bottom: 25px; width: 100%; text-align-last: justify; }
        #mprog { width: 240px; accent-color: darkgreen; outline: none; cursor: pointer; }
        #btnplay { width: 80px; height: 80px; cursor: pointer; animation: rotating 6s infinite linear var(--state); }
        @keyframes rotating { to { transform: rotate(360deg); } }
        @keyframes lrcGo0 { to { clip-path: inset(0 0 0 0); } }
        @keyframes lrcGo1 { to { clip-path: inset(0 0 0 0); } }
&lt;/style&gt;

<span class="tGreen">&lt;!--***** HTML代码 *****--&gt;</span>
&lt;div id="papa"&gt;
        &lt;audio id="aud" src="https://music.163.com/song/media/outer/url?id=212524" autoplay loop&gt;&lt;/audio&gt;
        &lt;div id="lrc" data-lrc="HuaChao LRC"&gt;HuaChao LRC&lt;/div&gt;
        &lt;div id="mplayer" data-tt="0:00 0:00"&gt;
                &lt;img id="btnplay" src="https://638183.freep.cn/638183/small/002_133507167677724892.png" title="播放/暂停" alt="" /&gt;&lt;br&gt;
                &lt;input id="mprog" type="range" min="0" max="100" step="any" value="0" title="调节进度" /&gt;
        &lt;/div&gt;
&lt;/div&gt;

<span class="tGreen">&lt;!--***** JavaScript代码 *****--&gt;</span>
&lt;script&gt;

<span class="tGreen">/* 增加两个全局变量 : aniIdx - 动画名称索引;lrcKey : 当前唱到的lrc歌词序号 */</span>
var mseek = false, <span class="tRed">aniIdx = 0, lrcKey = 0</span>;

<span class="tGreen">/* 按钮、歌词等状态函数 :父元素 papa 掌控 --state 变量,控制歌词同步与按钮 */</span>
var mState = () =&gt; aud.paused ?
        ( <span class="tRed">papa</span>.style.setProperty('--state', 'paused'), btnplay.title = '点击播放' ) :
        ( <span class="tRed">papa</span>.style.setProperty('--state', 'running'), btnplay.title = '点击暂停' );

<span class="tGreen">/* 秒数变分秒函数 */</span>
var toMin = (val) =&gt; { if(!val) return '0:00'; var min = parseInt(val / 60), sec = Math.floor(val) % 60; if(sec &lt; 10) sec = '0' + sec; return min + ':' + sec; };

<span class="tGreen">/* 显示歌词函数 :time 参数为动画运行时长 */</span>
var showLrc = (time) =&gt; {
        lrc.textContent = lrc.dataset.lrc = geci.replace(/&lt;br&gt;/, '\n');
        lrc.style.setProperty('--ani', ['lrcGo0','lrcGo1']);
        lrc.style.setProperty('--duration', time + 's');
        papa.style.setProperty('--state', 'running');
        aniIdx = aniIdx === 0 ? 1 : 0;
        lrcKey ++;
};

<span class="tGreen">/* 计算歌词索引函数 :用于 onseeked 监听事件,本节处理lrcKey归零问题,其余内容待补充 */</span>
var calcKey = () =&gt; lrcKey = 0;

<span class="tGreen">/* audio timeupdate 监听事件 */</span>
aud.addEventListener('timeupdate', () =&gt; {
        if (!mseek) mprog.value = aud.currentTime / aud.duration * mprog.max;
        mplayer.dataset.tt = toMin(aud.currentTime) + ' ' + toMin(aud.duration);
        <span class="tGreen">/* 循环歌词 */</span>
        for (let j = 0; j &lt; geci.length; j ++) {
                if (aud.currentTime &gt;= geci) { <span class="tGreen">/* 比对当前播放位置时间和歌词起唱时间 : 若成立 */</span>
                        if (j === lrcKey) showLrc(geci); <span class="tGreen">/* 再比对上述条件成立时的 j 和 lrcKey,成立则运行函数 show(time)驱动新歌词运行 */</span>
                }
        }
});

aud.addEventListener('pause', () =&gt; mState()); <span class="tGreen">/* 监听onpause事件 */</span>
aud.addEventListener('playing', () =&gt; mState()); <span class="tGreen">/* 监听playing事件 */</span>
<span class="tRed">aud.addEventListener('seeked', () =&gt; calcKey());</span> <span class="tGreen">/* 监听 onseeked 时间,重新播放时运行 calcKey() 函数重新计算 lrcKey(这里归零) */</span>

mprog.onmousedown = () =&gt; mseek = true;
mprog.onmouseup = () =&gt; mseek = false;
mprog.onchange = () =&gt; aud.currentTime = aud.currentTime = mprog.value / mprog.max * aud.duration;

btnplay.onclick = () =&gt; aud.paused ? aud.play() : aud.pause();

<span class="tGreen">/* 声明歌词数组 */</span>
var geci = [ , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , ];

&lt;/script&gt;
</pre>
<p>以上代码还不是range+lrc播放器的终结版,但已经可以正常运行,可以到 <a href="http://mhh.52qingyin.cn/api/pcode/" target="_blank">pencil code</a> 运行,或存为本地html文档后运行以查看效果。</p>
<p>本节理解起来有一定的难度,用心领会,做到全理解,将有助于提升解决细节问题的能力乃至编程思想,做不到全理解也没关系,学会将已经业已编写好的函数用到自己的程序中也很不错,能根据自己的需要修改一下函数则更好。下一讲,我们将完善 calcKey 函数,以便手动调整音频播放进度时lrc歌词同步能更好地工作。</p>

</div>

红影 发表于 2024-2-3 10:57

原来整个歌词同步的电脑一直在运算和比较着,能驱动电脑运算得到想要的歌词,黑黑太厉害了{:4_199:}

红影 发表于 2024-2-3 11:04

let gc2 = geci; 以前对这样的读取方式不熟,看了帖子明白了,原来第一个下标是按次序去找到那个想要的数组,第二个下标进一步告诉想要数组里的谁。就像将军发令:来呀,去把第二排的第三个士兵给我抓来,那家伙敢瞪我。数组就是那一排排的士兵,当然,他们只有3列{:4_173:}

亚伦影音工作室 发表于 2024-2-3 11:14

太详细了,老师懂得太多了,我们只会吃现成的饭!

红影 发表于 2024-2-3 11:21

两个嵌套的 if 语句很巧妙,不管是风动心动,第一个动,第二个必须动,而且第二个瞬生瞬死。保证了歌词能在自己的时间段里被留在那,时间一到就走人。

马黑黑 发表于 2024-2-3 12:18

红影 发表于 2024-2-3 10:57
原来整个歌词同步的电脑一直在运算和比较着,能驱动电脑运算得到想要的歌词,黑黑太厉害了

编程就是做算法,广义的算法指设计好机制后布置代码,将代码交给计算机执行。

马黑黑 发表于 2024-2-3 12:18

红影 发表于 2024-2-3 11:04
let gc2 = geci; 以前对这样的读取方式不熟,看了帖子明白了,原来第一个下标是按次序去找到那个想要 ...

理解正确

马黑黑 发表于 2024-2-3 12:19

亚伦影音工作室 发表于 2024-2-3 11:14
太详细了,老师懂得太多了,我们只会吃现成的饭!

{:4_190:}

马黑黑 发表于 2024-2-3 12:19

红影 发表于 2024-2-3 11:21
两个嵌套的 if 语句很巧妙,不管是风动心动,第一个动,第二个必须动,而且第二个瞬生瞬死。保证了歌词能在 ...

机巧,紧凑

红影 发表于 2024-2-3 14:12

马黑黑 发表于 2024-2-3 12:18
编程就是做算法,广义的算法指设计好机制后布置代码,将代码交给计算机执行。

而计算机能一丝不苟地直行好每个代码要求{:4_187:}

红影 发表于 2024-2-3 14:12

马黑黑 发表于 2024-2-3 12:18
理解正确

机制理解了,还需要记住这个顺序是从0开始的,不是从1.

红影 发表于 2024-2-3 14:13

马黑黑 发表于 2024-2-3 12:19
机巧,紧凑

嵌套通常不容易懂,却是非常技巧{:4_187:}

马黑黑 发表于 2024-2-3 17:41

红影 发表于 2024-2-3 14:13
嵌套通常不容易懂,却是非常技巧

条件语句的嵌套,就是一层限制一层,外层条件符合,里头那层才会检测

马黑黑 发表于 2024-2-3 17:41

红影 发表于 2024-2-3 14:12
机制理解了,还需要记住这个顺序是从0开始的,不是从1.

数组元素下标从零起算,这个应该知道了的

马黑黑 发表于 2024-2-3 17:46

红影 发表于 2024-2-3 14:12
而计算机能一丝不苟地直行好每个代码要求

具体地说,是计算机按编好的预设去一步一步执行。JS是单线程执行任务,它确实会一丝不苟地去执行预设程序,然后可能会卡壳(术语叫阻塞),就会出错,所以复杂机制的程序设计里,需要编程人员知道会不会阻塞、用不用异步编程。我们加载第三方外部JS资源时,用 onload 事件就属于异步执行该事件内的代码,就是说,要加载的文档必须加载好后再运行onload事件内的代码,加载资源和执行事件内代码有个时间差,这就是异步。

红影 发表于 2024-2-3 18:24

马黑黑 发表于 2024-2-3 17:41
条件语句的嵌套,就是一层限制一层,外层条件符合,里头那层才会检测

嗯嗯,记得你讲过的。这里打算是复习呢{:4_171:}

红影 发表于 2024-2-3 18:25

马黑黑 发表于 2024-2-3 17:41
数组元素下标从零起算,这个应该知道了的

知道,我是提醒自己必须记着{:4_173:}

红影 发表于 2024-2-3 18:26

马黑黑 发表于 2024-2-3 17:46
具体地说,是计算机按编好的预设去一步一步执行。JS是单线程执行任务,它确实会一丝不苟地去执行预设程序 ...

这个时间差感觉不到的吧。而实际加载是有先后顺序的{:4_187:}

马黑黑 发表于 2024-2-3 19:43

红影 发表于 2024-2-3 18:26
这个时间差感觉不到的吧。而实际加载是有先后顺序的

不去处理它就能明显感觉得到:它因为出错不工作了

马黑黑 发表于 2024-2-3 19:44

红影 发表于 2024-2-3 18:25
知道,我是提醒自己必须记着

这个不必要提醒的,这是规定
页: [1] 2 3 4 5
查看完整版本: range进度条播放器+LRC歌词同步教程(三)