在花潮经典版播放器开发环节之功能实现机制(一) ,播放器已经具备交互性质的播放/暂停功能,按钮形状会响应音频的播放暂停状态自行改变,还实现了包含进度条和时间信息在内的音频播放进度指示。接下来的环节将处理音频播放的进度干预问题,用户通过拖曳滑块或点击进度条能改变音频的播放位置。这是一个相对复杂的问题,我们一步一步来处理,第一步是获得设备指针坐标点数据、依此数据提供的X坐标计算出当前点离进度条左端的距离,然后和音频进度建立关联。
JS获取设备坐标点数据有不少原生方法,考虑到进度条之上有滑块,使用指针事件对象 event 的 clientX 获得当前指针所在点离应用程序(一般指浏览器)视口左边缘的距离(假设为x),再减去 progress 进度条离应用程序(浏览器)视口左边缘的距离(假设为left),x-left,就能得到指针拖曳或点击进度条上时当前点距离进度条左端的长度。获得进度条离浏览器左边缘的距离需要用到一个JS API,getBoundingClientRect,它将让我们获得基于元素的盒子模型的一些有用的数据。下面以鼠标点击进度条上任意地方为例说明如何获取点击点离进度条左端的距离:
// 进度条点击事件(注意自定义的 e 参数,e与progress紧密关联,
// 是progress发起的event事件对象
progress.onclick = (e) => {
// 获取progress元素rect模型数据集合
const rect = progress.getBoundingClientRect();
// 拿到progress离浏览器视口左边缘的距离
const left = rect.left;
// 拿到progress实际宽度
const width = rect.width;
// 获取指针event事件对象提供的点坐标数据中的X坐标
const x = e.clientX;
// 计算点击点在progress上的实际长度
x = x - left;
// 计算百分比
const prg = x / width * 100;
// 给CSS变量 --prg 赋值以更新进度条
progress.style.setProperty('--prg', prg + '%');
};
如果进度条上没有其它元素,上述做法就显得很罗嗦,我们完全可以使用 e.offsetX 拿到点击点在进度条上的实际距离,但是别忘了进度条上有一个滑块,点击在它上面拿到的是该点离它自身而非进度条左边缘的距离,所以上面罗嗦的代码能保证拿到的数据是准确的,不论点击的对象是谁,只要在progress范围内即可。
上面的代码将会在很多地方用到,所以应该将其封装成一个函数。以下封装的函数中考虑了移动端的适配等问题:
const getPercent = (e) => {
const rect = progress.getBoundingClientRect();
const left = rect.left;
const width = rect.width;
// 结合 ?? 和 ?. 运算符获取 e.clientX
let x = e.clientX ?? e.touches?.[0]?.clientX ?? e.changedTouches?.[0]?.clientX;
x = Math.min(width, Math.max(0, x - left));
return x / width * 100;
};
第六行代码就是适配PC端和移动端的关键。这里,使用空值合并运算符 ?? 和可选链运算符 ?. 联合、嵌套操作,先判断PC端鼠标指针的 e.clientX 是不是存在,亦即,我们首先读取的是PC端的 e.clicentX 值,如果该值存在说明是鼠标指针在操作,x 就直接等于 e.clientX,后面的判断不用管了;如果不存在说明数据来自是触控设备的操作,e 的背后是移动端,这需要分两种情形,一是 e.touches,二是 e.changedTouches,所以再使用一次 ?? 运算符查询数据来源是否来自 e.touches 即单个触控点,如是,先获取该数控点的第一个(数组下标[0])表示第一个),如果获取到了,后面的指令不再理睬,反之,没取到的话,再去查找连续变更中的触点列表 e.changedTouches 中的第一个拿到 clientX 数据,这些都是和触屏相关,具体指触屏点列表、连续触屏点列表等等,比较抽象不过也不难理解(想一想我们在手机上的点击、拖曳操作应该能大致明白其中的道理)。这一波折腾有点复杂,为了适配移动端,无法回避,否则拖曳滑块功能在移动端或触控端无法实现。这里采用了最为简洁的方式处理适配触控设备,抽象是简洁的代价,同时也存在兼容浏览器层面的问题:古董级别内核的浏览器环境可能不支持控制运算符和可选链运算符,这个这里就不管那么多了。
第七行代码考虑的是限制 x 值的范围,确保它不突破 progress 元素的左端和右端。原因是,指针或触屏操作不仅仅只是在进度条之上,例如,情形之一,拖曳滑块,滑块开始时有一半在进度条左边缘左侧,拖到最右边时,有一半在进度条的右侧,情形之二,拖曳过程中指针或手指可能会脱离进度条范围之后才松开、拿开,而设计者则应承认使用者的拖曳操作。所以嵌套使用了JS数学方法 Math.min(取最小值)和 Math.max(取最大值)来确保获取到的数据是从 0 到进度条长度 width 的范围(含 0 和 width)。原理:Math.min(a, b) 将拿到a、b两个数中最小的那个,Math.max(a, b) 将拿到 a、b 两个数中最大的那么,现假设我们要处理一个数字 number,最小不能小于 0,则用 Math.max(0, number) 来约束它,这样,当 number < 0,number 被舍弃、取大于它的 0,最大不能大于 100,则用 Math.min(100, number) 来约束,这样,当 numbere > 100,number 被舍弃、取小于它的 100,两个式子合起来写成,Math.min(100, Math.max(0, number)),这就对应于第七行代码,100 对应 width,0 对应 0,x-left 对应 number。
注意函数的返回值设计,这里返回的是没有%的百分比,因为有一些地方不需要%符号,用到的到时再加上。
然后,在相关的点击、拖曳操作中都可以调用上述函数。看示例:
// 设计一个布尔变量 :记录当前拖曳操作状态(初始值为假)
let isDraggable = false;
// 滑块鼠标按下、触屏设备手指或触笔按下
thumb.onmousedown = thumb.ontouchstart = (e) => {
isDraggable = true; // 拖曳状态进行中
e.preventDefault(); // 阻止默认行为
};
// 文档指针松开、触屏设备手指或触笔弹开
document.onmouseup = document.ontouchend = (e) => {
// 松开时若拖曳状态为真,驱动 audio 改变播放进度
if (isDraggable) audio.currentTime = `${getPercent(e) * audio.duration / 100}`;
isDraggable = false; //然后拖曳状态为假
};
// 文档上指针或手指、触笔移动时
document.onmousemove = document.ontouchmove = (e) => {
if (!isDraggable) return; // 若不是拖曳状态则忽略之
// 反之,若处于拖曳状态,给CSS变量 --prg 赋值
progress.style.setProperty('--prg', `${getPercent(e)}%`);
// 给时间文本信息即mplayer伪元素 attr(data-time) 函数赋值
mplayer.dataset.time = `${formatTime(audio.duration * getPercent(e) / 100)} ${formatTime(audio.duration)}`;
};
// 进度条点击事件
progress.onclick = (e) => audio.currentTime = `${getPercent(e) * audio.duration / 100}`;
请留意上面代码中,鼠标按下、触屏设备手指或触笔按下针对的是 thumb 滑块,因为拖曳操作从它那里发起,而松开、弹起和移动针对的是 document,因为当拖曳状态发生后,指针、手指、触笔运行的范围可能超越了滑块甚至进度条的范围,为保证松开、弹开后和移动之时保持拖曳进程的运行,就必须得基于整个文档,虽然基于整个文档——它当然包含滑块、进度条在内,但拖曳事件触发相应功能得有个前提,即,必须是处于拖曳状态发生的时候(isDraggable=true)。最后的进度条点击事件,触控端兼容PC端,无需做额外处理。
从上面的代码中应该能够发现一个问题:拖曳操作产生的进度条进度指示、audio播放控件播放进度变更会和 audio.ontimeupdate 事件所做的事情相冲突,具体表现为:这边手动拖曳滑块,另一边 audio 标签再指挥滑块移动,这个得错开,不然会发生拖不顺畅、音频出现破音等现象。处理方法将在本环节代码整合中指出。下面是整合的环节代码:
<style>
.mplayer { position: relative; width: 300px; height: fit-content; display: flex; flex-direction: column; align-items: center; gap: 10px; margin: auto; margin-top: 100px; }
.mplayer::before { position: absolute; content: attr(data-time); width: 100%; text-align-last: justify; pointer-events: none; }
.btnPlay { width: 20px; height: 20px; cursor: pointer; position: relative; }
.btnPlay::after { position: absolute; content: ''; width: 100%; height: 100%; background: red; clip-path: var(--clip); }
.progress { --prg: 0%; position: relative; width: 100%; height: 20px; display: grid; place-items: center start; background: linear-gradient(90deg, red var(--prg), gray var(--prg), gray 0) no-repeat center/100% 2px; padding: 0; margin: 0; }
.thumb { position: absolute; left: calc(var(--prg) - 10px); width: 20px; height: 20px; background: red; border: 8px solid green; border-radius: 50%; cursor: pointer; box-sizing: border-box; }
.play { --clip: polygon(10% 0,100% 50%,10% 100%); }
.pause { --clip: polygon(35% 0,15% 0,15% 100%, 35% 100%,35% 0,75% 0,75% 100%,55% 100%,55% 0); }
</style>
<audio src="https://music.163.com/song/media/outer/url?id=1933923242" autoplay loop></audio>
<div class="mplayer" data-time="00:00 00:00">
<div class="btnPlay play"></div>
<div class="progress">
<div class="thumb"></div>
</div>
</div>
<script>
//获取需要操作的元素标识
const mplayer = document.querySelector('.mplayer');
const btnPlay = document.querySelector('.btnPlay');
const progress = document.querySelector('.progress');
const thumb = document.querySelector('.thumb');
const audio = document.querySelector('audio');
// 拖曳操作状态(初始值为假)
let isDraggable = false;
//时间格式化工具函数 :秒转分秒 mm:ss 格式
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// 联动函数 mState :处理按钮形状
const mState = () => {
btnPlay.className = `btnPlay ${['pause', 'play'][+audio.paused]}`;
};
// 获取设备指针所在点在进度条上的距离(百分比)
const getPercent = (e) => {
const rect = progress.getBoundingClientRect();
const left = rect.left;
const width = rect.width;
let x = e.clientX ?? e.touches?.[0]?.clientX ?? e.changedTouches?.[0]?.clientX;
x = Math.min(width, Math.max(0, x - left));
return x / width * 100;
};
// 滑块鼠标按下、触屏设备手指或触笔按下
thumb.onmousedown = thumb.ontouchstart = (e) => {
isDraggable = true; // 拖曳状态进行中
e.preventDefault(); // 阻止默认行为
};
// 文档指针松开、触屏设备手指或触笔弹开
document.onmouseup = document.ontouchend = (e) => {
// 松开时若拖曳状态为真,驱动 audio 改变播放进度
if (isDraggable) audio.currentTime = `${getPercent(e) * audio.duration / 100}`;
isDraggable = false; //然后拖曳状态为假
};
// 文档上指针或手指、触笔移动时
document.onmousemove = document.ontouchmove = (e) => {
if (!isDraggable) return; // 若不是拖曳状态则忽略之
// 反之,若处于拖曳状态,给CSS变量 --prg 赋值
progress.style.setProperty('--prg', `${getPercent(e)}%`);
// 给时间文本信息即mplayer伪元素 attr(data-time) 函数赋值
mplayer.dataset.time = `${formatTime(audio.duration * getPercent(e) / 100)} ${formatTime(audio.duration)}`;
};
// 进度条点击事件
progress.onclick = (e) => audio.currentTime = `${getPercent(e) * audio.duration / 100}`;
// 音频标签开始播放和暂停时执行联动函数
audio.onplaying = audio.onpause = () => mState();
// 音频时间更新事件 :驱动文本时间信息及进度条进度变更
audio.ontimeupdate = () => {
if (isDraggable) return; // 拖曳操作发生时忽略
mplayer.dataset.time = `${formatTime(audio.currentTime)} ${formatTime(audio.duration)}`;
progress.style.setProperty('--prg', `${audio.currentTime / audio.duration * 100}%`);
};
// 按钮单击 :播放、暂停状态切换
btnPlay.onclick = () => audio.paused ? audio.play() : audio.pause();
</script>
点击预览上述代码的运行效果
本环节需要做一下总结:
(一)进度条滑块的拖曳操作以设备指针(含手指、鼻子、胳膊肘、触控笔等,后同)按下为先决条件,当指针在滑块之上按下,拖曳操作启动,直至指针弹起。期间的跟踪逻辑,移动和弹起均针对 document 对象,借助它可以监听到包含滑块在内的指针移动和弹起操作,以此保证对拖曳流程跟踪的完整性。这一过程以指针弹起作为结束信号。启动和结束拖曳均依托自定义变量 isDraggable 实现。
(二)我们使用div做进度条,它不像 input range 那样原生支持滑块的拖曳,所以拖曳操作需要额外做适配触控端的编程,以 touchstart 对应 mousedown、touchmove 对应 mousemove、touchend 对应 mouseup,并在获取触控点 e.clientX 时通过使用空值合并运算符 ?? 和可选链运算符 ?. 读取触控列表(touchList)中的数据,准确拿到触控屏上按下、拖曳、弹开等环节的 x 坐标数值。
(三)拖曳操作和音频播放过程均驱动播放器进度条滑块、时间信息框的变更,二者存在冲突,需要进行干预:拖曳开始时,isDraggable=true,audio 的 timeupdate 事件暂停驱动进度条和时间信息变更,拖曳结束时,isDraggable=false,audio 继续管理正常的驱动变更工作。