马黑黑 发表于 2022-9-1 22:35

开发html5音频播放器之JS篇

本帖最后由 马黑黑 于 2022-9-1 23:16 编辑

开发html5音频播放器之JS篇 | 马黑黑

我们在 ☞ 开发html5音频播放器之界面篇 里已经画好了播放器的界面与lrc歌词盒子,下面进入重点(也是难点):用 JS 实现音频播放器的相关功能。

一、准备工作

1. 音频地址 能播放的MP3或其他音频url
2. lrc歌词 可使用 ☞ 花潮lrc歌词在线 程序制作或转换,得出如下歌词数组:

let lrcAr = [
      ['00.01','梦蝴蝶 - 砍柴过岭又过坡'],
      ['23.71','砍柴过岭又过坡岭上山鸡尾拖拖'],
      ['31.71','岭上山鸡尾摆摆展翅飞过虎狼窝'],
      ['44.88','姐砍柴来妹相帮问你砍柴砍几长'],
      ['53.13','长长短短一下砍哪个带了尺来量'],
      ['74.68','砍柴莫砍岭上松小小松树有大用'],
      ['82.98','有日松树撑天起敢挡东南西北风'],
      ['96.12','进山听见斑鸠叫出山又闻鹧鸪啼'],
      ['104.32','一声山歌唱出口气死深山老画眉'],
      ['134.53','砍柴过岭又过坡岭上山鸡尾拖拖'],
      ['142.75','岭上山鸡尾摆摆展翅飞过虎狼窝'],
      ['155.90','姐砍柴来妹相帮问你砍柴砍几长'],
      ['164.16','长长短短一下砍哪个带了尺来量'],
      ['185.70','砍柴莫砍岭上松小小松树有大用'],
      ['194.01','有日松树撑天起敢挡东南西北风'],
      ['207.03','进山听见斑鸠叫出山又闻鹧鸪啼'],
      ['215.41','一声山歌唱出口气死深山老画眉']
];

二、开始编程

1. 创建一个 Audio 实体对象并做相关设置

let aud = new Audio();
aud.src = 'https://music.163.com/song/media/outer/url?id=367572.mp3';
aud.autoplay = true;
aud.loop = true;

首先,创建 Audio 实体对象 aud,接着,通过 aud 指定音频地址、设置播放器自动播放和循环播放。如此,只要浏览器支持声音的自动播放,这几句代码就能让音乐播放出来了。浏览器如果没有设置成自动播放声音或未开启本站点的声音自动播放,我们在前篇创建的按钮这时就派上用场了,但我们不具体操作播放按钮(当然也可以操作),而是针对按钮的父元素 #btnwrap 的单击事件做编程,以简化代码——

2.通过按钮控制音乐的播放与暂停

按钮父元素 btnwrap 的单击事件(onclick)代码如下:

btnwrap.onclick = () => aud.paused ? aud.play() : aud.pause();

当 btnwrap 按钮被单击,如果 aud 的暂停状态(aud.paused) 为真,则令其播放(aud.play()),反之,令其暂停(aud.pause())。我们使用的是三元运算,用以取代 if ... else ... 语句,使得代码更为简洁干练。三元运算符的问好(?)是询问前面的语句成立与否,问好后面的语句表示,成立的话执行这句,冒号(:)表示若不成立,就执行冒号后面那句。

我们在界面篇提到过,暂停与播放按钮在同一时间只出现其中的一个,为此,我们根据播放暂停逻辑编写一个函数设置按钮的显示/隐藏状态:

let btnstate = () => aud.paused ? (btnplay.style.display = 'block', btnpause.style.display = 'none') : (btnplay.style.display = 'none', btnpause.style.display = 'block');

函数名为 btnstate,它依据 aud 的暂停状态做判断,如果暂停为真,则令播放按钮可见、暂停按钮隐藏,否则,播放按钮隐藏、暂停按钮可见。这里的三元运算符每一段执行两个语句,所以问号后面和冒号后面的两个语句用括号括起来,括号内的语句有小角逗号隔开。

然后通过 aud 的两个监听事件来执行 btnstate 函数:

aud.addEventListener('pause', () => btnstate());
aud.addEventListener('play',() => btnstate());

一个是监听暂停(pause)动作,另一个监听播放(play)动作,通过对这两个动作的监听,执行函数 btnstate(),令播放、暂停按钮按预设逻辑显示或隐藏。

3. 控制播放进度

这个还是比较容易的。我们可以通过元素的 offsetX 获得用户点击进度条的水平位置值,该值即为进度条所表示的将要播放的进度,然后通过数学计算得出 aud 当前要播放的时间进度(currentTime)应为多少:

prog.onclick = (e) => aud.currentTime = aud.duration * e.offsetX / prog.offsetWidth;

计算公式是,(音频总时长×进度条被点击的位置)÷ 进度条的总长度。理解它,仅需高小数学知识。

实例 aud 的 aud.currentTime 值当前播放时间,即可表示当前播放的时间值,也可以赋值给它令其从该值进行播放,实践单位为s(省略)。

进度条的 offsetX 指它被点击处离自己左端的位置长度,offsetWidth 指它自身的(总)长度。offsetX 的获取需要一个元素参照句柄,prog.onclick = (e) => 中的参数 e 代表 prog 被点击的点的句柄,e.offsetX 的由来是这样。但获取 prog 元素的长度,直接使用 prog.offsetWidth,也可以 prog.clientWidth 等。

4、显示播放进度、播放时间信息和歌词

这三样,都是通过监听 aud 的 (播放)时间更新事件即 timeupdate 来完成,所以放在一起讲解。监听 timeupdate 事件的代码语句如下:

aud.addEventListener('timeupdate', () => {
      //处理代码
});

我们先处理的代码是进度条的变化。界面篇提到过,我们会使用背景渐变色来表示播放进度,这里就是通过改变 linear-gradient 所表示的背景的大小,亦即 CSS 里的 background-size(JS要写成 backgroundSize)的值,来呈现播放进度。先给出代码再作解释:

prog.style.backgroundSize = prog.offsetWidth * aud.currentTime / aud.duration + 'px 2px';

进度条的背景大小,单位为“Xpx Ypx”,例如上句末尾小角引号里的 px 2px,其中,2px 是Y坐标方向的值,对应其高度为2,所以是 2px,Xpx是X坐标方向的值,它依据当前播放时间、音频总时长和进度条长度三者间的关系计算得出当前播放进度应如何体现在进度条上,具体算式是:

(进度条总长度×当前播放时间)÷音频总时长

接着处理播放时间消息。这个本来简单,仅需显示 aud.currentTime 和 aud.duration 便可,但它们是以秒为单位,我们需要将其转换成“分:秒”结构以提升阅读友好性,故此需要准备一个秒转换成分秒的函数(这个函数之前做过解释):

let toMin = (val)=> {
      if (!val) return '00:00';
      val = Math.floor(val);
      let min = parseInt(val / 60), sec = parseFloat(val % 60);
      if(min < 10) min = '0' + min;
      if(sec < 10) sec = '0' + sec;
      return min + ':' + sec;
}

这个函数独立存在,不能放置在 timeupdate 的监听代码里面,但我们会在监听 timeupdate 的处理代码中调用它来显示音频相关的时间信息:

tmsg.innerText = toMin(aud.currentTime) + ' | ' + toMin(aud.duration);

tmsg 是我们之前建立好的播放时间信息盒子,它的内部文本(innerText)内容为转换成“分:秒”结构的当前播放进度和音频总时长等时间信息,二者以符号“|”隔开。显示文本除了使用元素的 innerText,还可以使用 innerHTML,二者的区别是后者支持 HTML 标签(例如处理存在<br>等标签的文本),下同。

最后是lrc歌词处理的代码,依然放在 timeupdate 监听事件代码中。我们令JS不停地检测 aud 的当前播放时间(aud.currentTime),拿它和歌词数组 lrcAr 提供的时间记录作比较,如果当前播放时间大于等于某句歌词的时间记录,则令 lrc 盒子显示该句歌词。由于不止一句歌词,所以我们要用for语句进行循环比较:

      for(j=0; j<lrcAr.length; j++) {
                if(aud.currentTime >= lrcAr) lrc.innerText = lrcAr;
      }

前面,我们通过 花潮lrc歌词 构造的数组是一个二维数组,for 语句中的 第 j 句歌词,它的时间信息是 lrcAr,如果 aud.currentTime 大于等于这个时间信息,则令 lrc.innerText 的值为 lrcAr,即在 lrc 盒子显示该句歌词。

至此,HTML5音频播放器的开发工作全部完成,大家可以在消化了两个篇章的内容的基础上进行其他细节的完善和改动。

马黑黑 发表于 2022-9-1 22:36

附:演示代码
<style>
#papa { margin: auto; width: 1024px; height: 640px; box-shadow: 3px 3px 20px #000; display:grid; place-items: center; user-select: none; position: relative; }
#mplayer { position: absolute; width: fit-content; height: fit-content; display: flex; align-items: center; gap: 8px; }
#btnwrap { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: #ccc linear-gradient(to top right, rgba(255,0,0,.75), rgba(0,255,0,.75)); cursor: pointer; }
#btnwrap:hover { background: #000 linear-gradient(to top right, rgba(255,0,0,.75), rgba(0,255,0,.75)); }
#btnplay { width: 20px; height: 20px; background: #ccc; clip-path: polygon(0 0, 0% 100%, 100% 50%); }
#btnpause { width: 2px; height: 20px; border-style: solid; border-width: 0px 4px; border-color: transparent #eee; display: none; }
#prog { width: 300px; height: 2px; background: #ccc linear-gradient(to right,red,orange,green,tomato) no-repeat; background-size: 1px 2px; cursor: pointer; position: relative;}
#prog::before { position: absolute; content: ''; top: -7px; width: inherit; height: 15px; }
#prog:hover::before { background: rgba(0,255,0,.25); }
#tmsg { font: normal 16px sans-serif; color: lightgreen;}
#lrc { position: absolute; transform: translateY(-60px); font: bold 1.5em sans-serif; color: lightgreen; text-shadow: 1px 1px 2px #000; text-align: center; }
#lrc:hover, #tmsg:hover { color: green; }
</style>

<div id="papa">
        <div id="mplayer"><span id="btnwrap"><span id="btnplay"></span><span id="btnpause"></span></span><span id="prog"></span><span id="tmsg">00:00 | 00:00</span></div>
        <div id="lrc">lrc歌词</div>
</div>

<script>
let lrcAr = [
        ['00.01','梦蝴蝶 - 砍柴过岭又过坡'],
        ['23.71','砍柴过岭又过坡岭上山鸡尾拖拖'],
        ['31.71','岭上山鸡尾摆摆展翅飞过虎狼窝'],
        ['44.88','姐砍柴来妹相帮问你砍柴砍几长'],
        ['53.13','长长短短一下砍哪个带了尺来量'],
        ['74.68','砍柴莫砍岭上松小小松树有大用'],
        ['82.98','有日松树撑天起敢挡东南西北风'],
        ['96.12','进山听见斑鸠叫出山又闻鹧鸪啼'],
        ['104.32','一声山歌唱出口气死深山老画眉'],
        ['134.53','砍柴过岭又过坡岭上山鸡尾拖拖'],
        ['142.75','岭上山鸡尾摆摆展翅飞过虎狼窝'],
        ['155.90','姐砍柴来妹相帮问你砍柴砍几长'],
        ['164.16','长长短短一下砍哪个带了尺来量'],
        ['185.70','砍柴莫砍岭上松小小松树有大用'],
        ['194.01','有日松树撑天起敢挡东南西北风'],
        ['207.03','进山听见斑鸠叫出山又闻鹧鸪啼'],
        ['215.41','一声山歌唱出口气死深山老画眉']
];
let aud = new Audio();
aud.src = 'https://music.163.com/song/media/outer/url?id=367572.mp3';
aud.autoplay = true;
aud.loop = true;
btnwrap.onclick = () => aud.paused ? aud.play() : aud.pause();
prog.onclick = (e) => aud.currentTime = aud.duration * e.offsetX / prog.offsetWidth;
aud.addEventListener('pause', () => btnstate());
aud.addEventListener('play',() => btnstate());
aud.addEventListener('timeupdate', () => {
        prog.style.backgroundSize = prog.offsetWidth * aud.currentTime / aud.duration + 'px 2px';
        tmsg.innerText = toMin(aud.duration) + ' | ' + toMin(aud.currentTime);
        for(j=0; j<lrcAr.length; j++) {
                if(aud.currentTime >= lrcAr) lrc.innerText = lrcAr;
        }
});
let btnstate = () => aud.paused ? (btnplay.style.display = 'block', btnpause.style.display = 'none') : (btnplay.style.display = 'none', btnpause.style.display = 'block');
let toMin = (val)=> {
        if (!val) return '00:00';
        val = Math.floor(val);
        let min = parseInt(val / 60), sec = parseFloat(val % 60);
        if(min < 10) min = '0' + min;
        if(sec < 10) sec = '0' + sec;
        return min + ':' + sec;
}
</script>

马黑黑 发表于 2022-9-1 22:37

<style>
#papa { left: -214px; width: 1024px; height: 640px; box-shadow: 3px 3px 20px #000; display:grid; place-items: center; user-select: none; position: relative; }
#mplayer { position: absolute; width: fit-content; height: fit-content; display: flex; align-items: center; gap: 8px; }
#btnwrap { position: relative; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 50%; background: #ccc linear-gradient(to top right, rgba(255,0,0,.75), rgba(0,255,0,.75)); cursor: pointer; }
#btnwrap:hover { background: #000 linear-gradient(to top right, rgba(255,0,0,.75), rgba(0,255,0,.75)); }
#btnplay { width: 20px; height: 20px; background: #ccc; clip-path: polygon(0 0, 0% 100%, 100% 50%); }
#btnpause { width: 2px; height: 20px; border-style: solid; border-width: 0px 4px; border-color: transparent #eee; display: none; }
#prog { width: 300px; height: 2px; background: #ccc linear-gradient(to right,red,orange,green,tomato) no-repeat; background-size: 1px 2px; cursor: pointer; position: relative;}
#prog::before { position: absolute; content: ''; top: -7px; width: inherit; height: 15px; }
#prog:hover::before { background: rgba(0,255,0,.25); }
#tmsg { font: normal 16px sans-serif; color: lightgreen;}
#lrc { position: absolute; transform: translateY(-60px); font: bold 1.5em sans-serif; color: lightgreen; text-shadow: 1px 1px 2px #000; text-align: center; }
#lrc:hover, #tmsg:hover { color: green; }
</style>

<div id="papa">
        <div id="mplayer"><span id="btnwrap"><span id="btnplay"></span><span id="btnpause"></span></span><span id="prog"></span><span id="tmsg">00:00 | 00:00</span></div>
        <div id="lrc">lrc歌词</div>
</div>

<script>
let lrcAr = [
        ['00.01','梦蝴蝶 - 砍柴过岭又过坡'],
        ['23.71','砍柴过岭又过坡岭上山鸡尾拖拖'],
        ['31.71','岭上山鸡尾摆摆展翅飞过虎狼窝'],
        ['44.88','姐砍柴来妹相帮问你砍柴砍几长'],
        ['53.13','长长短短一下砍哪个带了尺来量'],
        ['74.68','砍柴莫砍岭上松小小松树有大用'],
        ['82.98','有日松树撑天起敢挡东南西北风'],
        ['96.12','进山听见斑鸠叫出山又闻鹧鸪啼'],
        ['104.32','一声山歌唱出口气死深山老画眉'],
        ['134.53','砍柴过岭又过坡岭上山鸡尾拖拖'],
        ['142.75','岭上山鸡尾摆摆展翅飞过虎狼窝'],
        ['155.90','姐砍柴来妹相帮问你砍柴砍几长'],
        ['164.16','长长短短一下砍哪个带了尺来量'],
        ['185.70','砍柴莫砍岭上松小小松树有大用'],
        ['194.01','有日松树撑天起敢挡东南西北风'],
        ['207.03','进山听见斑鸠叫出山又闻鹧鸪啼'],
        ['215.41','一声山歌唱出口气死深山老画眉']
];
let aud = new Audio();
aud.src = 'https://music.163.com/song/media/outer/url?id=367572.mp3';
aud.autoplay = true;
aud.loop = true;
btnwrap.onclick = () => aud.paused ? aud.play() : aud.pause();
prog.onclick = (e) => aud.currentTime = aud.duration * e.offsetX / prog.offsetWidth;
aud.addEventListener('pause', () => btnstate());
aud.addEventListener('play',() => btnstate());
aud.addEventListener('timeupdate', () => {
        prog.style.backgroundSize = prog.offsetWidth * aud.currentTime / aud.duration + 'px 2px';
        tmsg.innerText = toMin(aud.duration) + ' | ' + toMin(aud.currentTime);
        for(j=0; j<lrcAr.length; j++) {
                if(aud.currentTime >= lrcAr) lrc.innerText = lrcAr;
        }
});
let btnstate = () => aud.paused ? (btnplay.style.display = 'block', btnpause.style.display = 'none') : (btnplay.style.display = 'none', btnpause.style.display = 'block');
let toMin = (val)=> {
        if (!val) return '00:00';
        val = Math.floor(val);
        let min = parseInt(val / 60), sec = parseFloat(val % 60);
        if(min < 10) min = '0' + min;
        if(sec < 10) sec = '0' + sec;
        return min + ':' + sec;
}
</script>

红影 发表于 2022-9-1 23:20

val = Math.floor(val);是返回val的最大整数,但是val怎么对应歌词时间的看不出来。

青青子衿 发表于 2022-9-1 23:21

小马黑黑老师,是最好的老师,从零基础讲起呢。。。俺这几个月任务太多,无暇做帖子。。有点辜负老师的细心教导。忙过这几月,一定天天做帖子{:4_178:}

红影 发表于 2022-9-1 23:28

总觉得看JS有点吃力,主要是对各种命令的表述陌生。比如分的算法好理解,秒为什么是parseFloat(val % 60)

马黑黑 发表于 2022-9-1 23:36

红影 发表于 2022-9-1 23:20
val = Math.floor(val);是返回val的最大整数,但是val怎么对应歌词时间的看不出来。

Math.floor(val) 取 val 的向下整数,例如,2.6 取 2。

val 通过 toMin(val) 的调用者传递过来时间信息,比如:

toMin(aud.currentTime)

这将把 aud.currentTime 的数值交给 toMin 函数处理,aud.currentTime 数值格式为类似如下的数值:

27.0598

马黑黑 发表于 2022-9-1 23:42

红影 发表于 2022-9-1 23:28
总觉得看JS有点吃力,主要是对各种命令的表述陌生。比如分的算法好理解,秒为什么是parseFloat(val % 60)

内置的函数与方法、对象,这里都有,要一句一句、一个一个的理解。像Math是数学方法,Math.floor 是对浮点数向下取整数,new Audio() 是 audio api 的新建对象,true 和 false 是布尔真假值,length 是数组或字串等长度,offsetX、offsetY、offsetWidth、offsetHeight 是元素宽高、duation和currentTime以及 updatetime 之类的是 audio API提供的属性,在结合CSS的相关属性(如 style、block、backgroundSize 之类的),就显得很复杂,需要一一掌握后才能基本读懂全部JS代码。

马黑黑 发表于 2022-9-1 23:46

红影 发表于 2022-9-1 23:20
val = Math.floor(val);是返回val的最大整数,但是val怎么对应歌词时间的看不出来。

这与秒为什么用 parseFloat:parseFloat 是强制 val % 60 为浮点数,能让算式出现小数点

马黑黑 发表于 2022-9-1 23:46

青青子衿 发表于 2022-9-1 23:21
小马黑黑老师,是最好的老师,从零基础讲起呢。。。俺这几个月任务太多,无暇做帖子。。有点辜负老师的细心 ...

精神可嘉

加林森 发表于 2022-9-2 09:25

来学习!

马黑黑 发表于 2022-9-2 12:33

加林森 发表于 2022-9-2 09:25
来学习!

{:4_190:}

加林森 发表于 2022-9-2 12:58

马黑黑 发表于 2022-9-2 12:33


谢茶!

马黑黑 发表于 2022-9-2 13:15

加林森 发表于 2022-9-2 12:58
谢茶!

不客气

加林森 发表于 2022-9-2 13:24

马黑黑 发表于 2022-9-2 13:15
不客气

嗯嗯

红影 发表于 2022-9-2 14:42

马黑黑 发表于 2022-9-1 23:36
Math.floor(val) 取 val 的向下整数,例如,2.6 取 2。

val 通过 toMin(val) 的调用者传递过来时间信 ...

没在语句中看到这个传递,应该都是内置的吧。

红影 发表于 2022-9-2 14:45

马黑黑 发表于 2022-9-1 23:46
这与秒为什么用 parseFloat:parseFloat 是强制 val % 60 为浮点数,能让算式出现小数点

这些就比较复杂了,应该是取完分后,扣掉那个分,剩下那部分才是秒吧,看不出有这样的过程。

红影 发表于 2022-9-2 14:46

马黑黑 发表于 2022-9-1 23:42
内置的函数与方法、对象,这里都有,要一句一句、一个一个的理解。像Math是数学方法,Math.floor 是对浮 ...

很多的中间过程都打包了吧,所以总觉得JS难以看懂{:4_173:}

马黑黑 发表于 2022-9-2 19:18

红影 发表于 2022-9-2 14:46
很多的中间过程都打包了吧,所以总觉得JS难以看懂

怎么说呢,我使用大量的语法糖,这样代码量会减少,效率也会有所提高

马黑黑 发表于 2022-9-2 19:19

红影 发表于 2022-9-2 14:45
这些就比较复杂了,应该是取完分后,扣掉那个分,剩下那部分才是秒吧,看不出有这样的过程。

你这是常规计算。我采用的是取余数。
页: [1] 2 3
查看完整版本: 开发html5音频播放器之JS篇