相信大家都有过开发音乐播放器的需求,我也不例外,最近刚好就做了一个这样的需求,所以就顺便把遇到的问题和踩过的坑,分享一下~

因为项目是基于React,所以下面的源码基本都是在播放器组件中抽出来的,不过无论在什么框架下开发,逻辑是一样滴。

播放器皮肤

先从简单的开始讲起吧,说到播放器几个常用的操作按钮肯定少不了,但是最重要的是那一个可以拖动的播放条。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

<!--进度条begin-->
<div className="audio-progress" ref={(r) => { this.audioProgress = r }}>
<div className="audio-progress-bar" ref={(bar) => { this.progressBar = bar }}>
<div className="audio-progress-bar-preload" style={{ width: bufferedPercent + '%' }}></div>
<div className="audio-progress-bar-current" style={{ width: `calc(${currentTime}/${currentTotalTime} * ${this.progressBar && this.progressBar.clientWidth}px)` }}></div>
</div>
<div className="audio-progress-point-area" ref={(point) => { this.audioPoint = point }} style={{ left: left + 'px' }}>
<div className="audio-progress-point">

</div>
</div>
</div>
<!--进度条begin-->
<div className="audio-timer">
<p>{this.renderPlayTime(currentTime)}</p>
<p>{this.renderPlayTime(currentTotalTime)}</p>
</div>

<div className="audio-control">
<div className="audio-control-pre">
<img src={PreIcon} alt="" />
</div>
<div className="audio-control-play">
<img src={Play} alt="" />
</div>
<div className="audio-control-next">
<img src={NextIcon} alt="" />
</div>
</div>

进度条的大致原理就是获取音频的当前播放时长以及音频总时长的比例,然后通过这个比例与进度条宽度相乘,可以得到当前播放时长下进度条需要被填充的宽度。

代码中的“audio-progress-bar-preload”是用来做缓冲条的,大概的做法也是一样,不过获取缓冲进度得用到audio的buffered属性,具体的用法推荐大家去MDN看看,在这里就不多赘述。

进度条以及播放按钮的布局代码大概就是这样,在css方面需要注意的就是进度条容器与进度条填充块以及进度条触点间的层级关系就好。

播放器功能逻辑

也许看了上面,大家觉得很疑惑,为什么没有见到最关键的audio标签。那是因为,其实整个播放器的逻辑重点都在那个标签里,我们需要单独抽出来分析一下。

1
2
3
4
5
6
7
8
<audio 
preload="metadata"
src={src}
ref={(audio) => {this.lectureAudio = audio}}
style={{width: '1px', height: '1px', visibility: 'hidden'}}
onCanPlay={() => this.handleAudioCanplay()}
onTimeUpdate={() => this.handleTimeUpdate()}>
</audio>

① 怎么让进度条动起来?

解决思路–音频在播放的时候,当前进度(currentTime)是一直都在变化的,也就是说,能找到一个将currentTime变化具现为进度条长度的常数是关键。说的这么复杂,其实在上面的布局代码里已经剧透了,

1
calc(${currentTime}/${currentTotalTime} * $this.progressBar.clientWidth}px

这样就能很轻松的算出当前进度需要对应的进度条填充长度。问题又来了,我知道进度条该填充多长了,但是它还是不会动额…
在这里,我们有两个方法可以解决:

  • 利用setInterval
    我们可以每隔300ms就检测一下当前audio的currentTime,然后在setState动态改变state中的currentTime,接着组件便会重渲染进度条部分的展示,从而也就让我们的进度条动起来了。
  • 利用audio的ontimeupdate事件
    这个是audio和video都拥有的原生事件,作用是–“当currentTime更新时会触发timeupdate事件”,一般推荐使用这种方式来动态计算进度条的宽度,毕竟可以少写一个计时器,说不定可以规避一些项目中的隐患。而且这是HTML的原生事件,浏览器的支持肯定是充分的,所以从性能的角度来说应该是比上一种方式要好。

ontimeupdate时执行的方法–每次触发该事件时都重新给currentTime赋值,剩下的改动都可以通过currentTime值的变化而做出相应的变化。

1
2
3
4
5
6
7
8
9
handleTimeUpdate() {
if (this.state.currentTime < (this.state.currentTotalTime - 1)) {
this.setState({
currentTime: this.lectureAudio.currentTime
});
} else {
......
}
}

② 怎么在移动端拖动进度条?

解决思路–既然是移动端的触碰事件,那么touch事件自然就是主角了。通过touch事件我们可以计算出,拖动的距离,进而得到进度条以及触点该移动的距离。

1
2
3
4
5
initListenTouch() {
this.audioPoint.addEventListener('touchstart', (e) => this.pointStart(e), false);
this.audioPoint.addEventListener('touchmove', (e) => this.pointMove(e), false);
this.audioPoint.addEventListener('touchend', (e) => this.pointEnd(e), false);
}

这是组件加载时挂在到进度条触点的三个事件监听,这里讲一下三个监听具体都有些什么作用。

  • touchstart–负责获取触摸进度触点时触点的方位

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    pointStart(e) {
    e.preventDefault();
    let touch = e.touches[0];
    this.lectureAudio.pause();
    //为了更好的体验,在移动触点的时候我选择将音频暂停
    this.setState({
    isPlaying: false,//播放按钮变更
    startX: touch.pageX//进度触点在页面中的x坐标
    });
    }
  • touchmove–负责动态计算触点的拖动距离,并转换成this.state.currentTime从而触发组件的重渲染.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    pointMove(e) {
    e.preventDefault();
    let touch = e.touches[0];
    let x = touch.pageX - this.state.startX; //滑动的距离
    let maxMove = this.progressBar.clientWidth;//最大的移动距离不可超过进度条宽度

    //(this.state.moveX) = this.lectureAudio.duration / this.progressBar.clientWidth;
    //moveX是一个固定的常数,它代表着进度条宽度与音频总时长的关系,我们可以通过获取触点移动的距离从而计算出此时对应的currentTime
    //下面是触点移动时会碰到的情况,分为正移动、负移动以及两端的极限移动。
    if (x >= 0) {
    if (x + this.state.startX - this.offsetWindowLeft >= maxMove) {
    this.setState({
    currentTime: this.state.currentTotalTime,
    //改变当前播放时间的数值
    }, () => {
    this.lectureAudio.currentTime = this.state.currentTime;
    //改变audio真正的播放时间
    })
    } else {
    this.setState({
    currentTime: (x + this.state.startX - this.offsetWindowLeft) * this.state.moveX
    }, () => {
    this.lectureAudio.currentTime = this.state.currentTime;
    })
    }
    } else {
    if (-x <= this.state.startX - this.offsetWindowLeft) {
    this.setState({
    currentTime: (this.state.startX + x - this.offsetWindowLeft) * this.state.moveX,
    }, () => {
    this.lectureAudio.currentTime = this.state.currentTime;
    })
    } else {
    this.setState({
    currentTime: 0
    }, () => {
    this.lectureAudio.currentTime = this.state.currentTime;
    })
    }
    }
    }
  • touchend–负责恢复音频的播放

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    pointEnd(e) {
    e.preventDefault();
    if (this.state.currentTime < this.state.currentTotalTime) {
    this.touchendPlay = setTimeout(() => {
    this.handleAudioPlay();
    }, 300)
    }
    //关于300ms的setTimeout,一是为了体验的良好,大家在做的时候可以试试不要300ms的延迟,会发现收听体验不好,音频的播放十分仓促。
    //另外还有一点是,audio的pause与play间隔过短会出现报错,导致audio无法准确的执行相应的动作。
    }

③ 怎么实现列表播放与循环播放?

解决思路–时刻关注currentTime与duration之间的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
handleTimeUpdate() {
if (this.state.currentTime < (this.state.currentTotalTime - 1)){
......
}else {
//此情况为音频已播放至最后
if (this.state.isLooping){//是否为循环播放
//currentTime归零,并且手动将audio的currentTime设为0,并手动执行play()
this.setState({
currentTime: 0
}, () => {
this.lectureAudio.currentTime = this.state.currentTime;
this.lectureAudio.play();
})
}else {
//列表播放则只需判断是否有下一首,有则跳转或播放,无则暂停播放。
if (this.props.audioInfo.next_lecture_id && this.state.currentTime !== 0){
this.handleNextLecture();
}else {
this.handleAudioPause();
}
}
}
}

总结

不知道大家看完之后有没一种感觉,好像无论是什么都离不开this.state中的currentTime。

是的,这个播放器的核心就是currentTime,这也是开发时的刻意为之,最后我们会发现这个组件中的唯一变量就是currentTime,我们可以通过currentTime的变化完成所有的需求,并且不需要考虑其他因素的影响,因为所有的子组件都是围绕着currentTime运转。

以上是我关于播放器开发的一点小心得,虽然没有十分酷炫的皮肤,也没有十分复杂的功能,但是对个人而言,它很好的帮我理清了一个[可用的]播放器的开发流程。