简介
记录关于自己使用 Web Audio API 的 AudioContext 播放音乐的知识点。
需求分析
1.列表展示音乐;
2.上/下一首、播放/暂停/续播;
3.播放模式切换:循环播放、单曲循环、随机播放;
4.播放状态显示:当前播放的音乐名、播放时间、总时间、进度条效果;
5.播放控制器显示在底部区域;
6.支持音量调节;
7.浏览器隐藏、显示的交互后,也能正常有效播放(播放、声音)。
注意
安卓
与IOS
上有不同的兼容性,所以采用了 Web Audio API 的 AudioContext ,兼容性强大(但是截止写文章前,IOS17+版本不支持,没有声音
)。
稍微复杂点点的逻辑就是AudioContext与手机系统的关联,可以看看 AudioContext: createMediaElementSource。
具体实现
test/music/musicPlayer/musics.ts
test/music/musicPlayer/useMusicPlayer.ts
test/music/index.vue
1.test/music/musicPlayer/musics.ts
interface musicItem {
title: string
src: string
time: string
mp3Name: string
}
const musicList: musicItem[] = [
{
title: 'How to Love',
src: '',
time: '03:39',
mp3Name: 'sx_music_HowtoLove_CashCash'
},
{
title: '空空如也',
src: '',
time: '03:34',
mp3Name: 'sx_music_kongkongruye'
},
{
title: '2 Soon',
src: '',
time: '03:19',
mp3Name: 'sx_music_Soon_JonYoung'
},
{
title: '孤勇者',
src: '',
time: '04:16',
mp3Name: 'sx_music_guyongzhe'
},
{ title: '秒针', src: '', time: '02:58', mp3Name: 'sx_music_miaozhen' },
{
title: '热爱105˚的你',
src: '',
time: '03:15',
mp3Name: 'sx_music_reai105dudeni'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
},
{
title: '她会魔法吧',
src: '',
time: '03:01',
mp3Name: 'sx_music_tahuimofaba'
}
] // 音乐列表信息
export { type musicItem, musicList }
2.test/music/musicPlayer/useMusicPlayer.ts
import { ref, nextTick } from 'vue'
import { musicList } from './musics'
enum PlayMode {
REPEAT, // 循环播放
SINGLE_CYCLE, // 单曲循环
RANDOM // 随机播放
}
const musicPlayer = ref<HTMLAudioElement | null>()
const musicPlayingIndex = ref(-1) // 播放的音乐的下标
const musicIsPlaying = ref(false) // 是否播放中
const currentTime = ref(0) // 正在播放的音乐时间点
const musicPlayMode = ref(PlayMode.REPEAT) // 播放模式
const progressInterval = 500 // 计时器触发的频率
let defaultVolume = 1 // 音量 0-1
let timer: NodeJS.Timer | null = null // 计时器 ---此处需要在 .eslintrc.js/.cjs 文件中配置 globals: { NodeJS: true }
let source: MediaElementAudioSourceNode | null = null
let audioCtx: AudioContext | null = null
let gainNode: GainNode | null = null
let audioContextAttr: string | null = null
if ('AudioContext' in window) {
audioContextAttr = 'AudioContext'
} else if ('webkitAudioContext' in window) {
audioContextAttr = 'webkitAudioContext'
}
const useMusicPlayer = () => {
const _getMusicFile = (mp3Name: string) => {
// 此处需要相对路径
// vite项目
// return new URL(`../../../assets/music/${mp3Name}.mp3`, import.meta.url).href
// webpack项目
return require(`../../../assets/music/${mp3Name}.mp3`)
}
/** 设置:音量百分比 0-100 变为 0-1
* @param v number 0-100
*/
const _saveDefaultVolume = (v: number) => {
let num = v
if (v < 0) {
num = 0
} else if (v > 100) {
num = 100
}
defaultVolume = num / 100
return defaultVolume
}
/**
* 计时器:回调-更新显示-MP3的播放时间
*/
const _intervalUpdatePlayTime = () => {
const player = musicPlayer.value
if (!player) return
currentTime.value = player.currentTime
}
/**
* 计时器:清除
*/
const _clearTimer = () => {
if (!timer) return
clearInterval(timer)
timer = null
}
/**
* 计时器:绑定&开始
*/
const _startTimer = () => {
_clearTimer()
timer = setInterval(_intervalUpdatePlayTime, progressInterval)
}
/**
* 方法:取两个值之间的随机数
*/
const _random = (min = 0, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min
/**
* 销毁:断开audio与AudioContext之间的链接
*/
const _destroyConnect = () => {
if (source) {
source.disconnect()
}
if (gainNode) {
gainNode.disconnect()
}
if (audioCtx) {
audioCtx.close()
}
source = null
gainNode = null
audioCtx = null
musicPlayer.value = null
}
/**
* 音乐:初始化audio与AudioContext的绑定
* 目的是为了 IOS 上能调整音量
*/
const _init = () => {
if (!audioContextAttr) return
// 先暂停已有的播放
pause()
// 对已创建的绑定关系进行解绑
_destroyConnect()
// 若在body中找得到对应的dom,则进行移除
const findDom = document.getElementById('musicPlayerAudio') as HTMLAudioElement
if (findDom) {
findDom.remove()
}
// 创建audio,加入body中
const dom = document.createElement('audio')
dom.id = 'musicPlayerAudio'
document.body.appendChild(dom)
// 给audio绑定播放结束的回调函数
dom.onended = onAudioEnded
// 创建AudioContext、source、gainNode,进行关联(便于IOS控制音量)
const UseAudioContext = (window as any)[audioContextAttr]
audioCtx = new UseAudioContext()
if (!audioCtx) return
source = audioCtx.createMediaElementSource(dom)
gainNode = audioCtx.createGain()
source.connect(gainNode)
gainNode.connect(audioCtx.destination)
// 设置音量
if (defaultVolume === 0) {
dom.muted = true
} else {
dom.muted = false
}
gainNode.gain.value = defaultVolume
// 存储dom,便于后续访问audio对应的属性
musicPlayer.value = dom
// 若播放控制器的状态未启动,则启动
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume()
}
}
/**
* 音乐:播放器-音量调整
*/
const setVolume = (volume: number) => {
const v = _saveDefaultVolume(volume)
const player = musicPlayer.value
if (!player) return
if (v === 0) {
player.muted = true
} else {
player.muted = false
}
if (!gainNode || !gainNode.gain) return
gainNode.gain.value = v
}
/**
* 音乐:播放器-暂停
*/
const pause = () => {
const player = musicPlayer.value
if (!musicIsPlaying.value || !player) {
return
}
musicIsPlaying.value = false
player.pause()
_clearTimer()
}
/**
* 音乐:播放器-播放
*/
const playByLast = () => {
const player = musicPlayer.value
if (!player || !player.src) return
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume()
}
nextTick(() => {
// play触发时,会先自动加载资源
player.play().then(() => {
musicIsPlaying.value = true
_startTimer()
})
})
}
/**
* 音乐:播放器-播放-通过下标
*/
const playByIndex = (index: number) => {
if (index < 0 || index + 1 > musicList.length) {
return
}
musicIsPlaying.value = false
// 重新初始化,便于释放上一个播放器所占用的内存
_init()
const player = musicPlayer.value
if (!player) {
return
}
// 重置当前播放了的时长
currentTime.value = 0
// 更新要播放的下标
musicPlayingIndex.value = index
if (!musicList[index].src) {
// 若资源路径不存在,则进行对应的路径引入
musicList[index].src = _getMusicFile(musicList[index].mp3Name)
}
if (!musicList[index].src) {
console.error('find music file failed')
return
}
player.src = musicList[index].src
playByLast()
}
/**
* 音乐:随机播放
*/
const randomPlay = () => {
const index = _random(0, musicList.length - 1)
playByIndex(index)
}
/**
* 音乐:播放器-下一首
*/
const playNext = () => {
if (musicPlayMode.value === PlayMode.RANDOM) {
randomPlay()
} else {
const index: number =
musicPlayingIndex.value + 1 === musicList.length ? 0 : musicPlayingIndex.value + 1
playByIndex(index)
}
}
/**
* 音乐:播放器-上一首
*/
const playPrev = () => {
if (musicPlayMode.value === PlayMode.RANDOM) {
randomPlay()
} else {
const index: number =
musicPlayingIndex.value < 1 ? musicList.length - 1 : musicPlayingIndex.value - 1
playByIndex(index)
}
}
/**
* 回调:播放结束后,下一首播放什么
*/
const onAudioEnded = () => {
switch (musicPlayMode.value) {
case PlayMode.REPEAT:
playNext()
break
case PlayMode.SINGLE_CYCLE:
playByIndex(musicPlayingIndex.value)
break
case PlayMode.RANDOM:
randomPlay()
break
default:
break
}
return true
}
/** 自动播放音乐 */
const startPlayInRoom = () => {
// 用户第一次点击时,自动播放音乐
const initMusicAutoPlayOnReload = () => {
document.removeEventListener('click', initMusicAutoPlayOnReload, true)
playByIndex(0)
}
document.addEventListener('click', initMusicAutoPlayOnReload, true)
}
return {
musicList,
musicPlayer,
musicPlayingIndex,
musicIsPlaying,
currentTime,
musicPlayMode,
setVolume,
pause,
playByIndex,
playByLast,
playPrev,
playNext,
_clearTimer,
startPlayInRoom
}
}
export { PlayMode, useMusicPlayer }
3.test/music/index.vue
<template>
<div class="music-box">
<!-- 音乐列表 -->
<div class="music-list">
<div
v-for="(music, index) in musicList"
:key="index"
class="music-item"
:class="{ 'music-item-active': musicPlayer.musicPlayingIndex.value === index }"
@click.stop="switchAudio(index)"
>
<div class="item-left">
<div class="item-left-title">
{{ music.title }}
</div>
<svg
v-if="musicPlayer.musicPlayingIndex.value === index && musicPlayer.musicIsPlaying.value"
id="equalizer"
width="13px"
height="11px"
viewBox="0 0 10 7"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g fill="#3994f9">
<rect
id="bar1"
transform="translate(0.500000, 6.000000) rotate(180.000000) translate(-0.500000, -6.000000) "
x="0"
y="5"
width="1"
height="2px"
></rect>
<rect
id="bar2"
transform="translate(3.500000, 4.500000) rotate(180.000000) translate(-3.500000, -4.500000) "
x="3"
y="2"
width="1"
height="5"
></rect>
<rect
id="bar3"
transform="translate(6.500000, 3.500000) rotate(180.000000) translate(-6.500000, -3.500000) "
x="6"
y="0"
width="1"
height="7"
></rect>
</g>
</svg>
<svg
v-else-if="musicPlayer.musicPlayingIndex.value === index"
id="equalizer"
width="13px"
height="11px"
viewBox="0 0 10 7"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g fill="#3994f9">
<rect x="0" y="5" width="1" height="2px"></rect>
<rect x="3" y="2" width="1" height="5"></rect>
<rect x="6" y="0" width="1" height="7"></rect>
</g>
</svg>
</div>
<div class="item-right">
{{ music.time }}
</div>
</div>
</div>
<!-- 播放控制 -->
<div class="music-control">
<div class="control-content">
<div class="control-content-left">
<div
class="music-btn prev"
@touchstart.passive="onTouchEvent"
@touchend.passive="onTouchEvent"
@click="prev"
/>
<div
:class="['music-btn', musicPlayer.musicIsPlaying.value ? 'pause' : 'play']"
@touchstart.passive="onTouchEvent"
@touchend.passive="onTouchEvent"
@click="togglePlayer"
/>
<div
class="music-btn next"
@touchstart.passive="onTouchEvent"
@touchend.passive="onTouchEvent"
@click="next"
/>
</div>
<div class="control-content-center">
<div class="center-title">
{{ currentMusicTitle || '-' }}
</div>
<div ref="audioProgressWrap" class="center-progress-wrap">
<div ref="audioProgress" class="center-progress-wrap-active" />
</div>
<div class="center-time">
<div class="center-time-now">
{{ formatSecond(musicPlayer.currentTime.value) }}
</div>
<div class="center-time-total">
{{ currentMusicTotalTimeStr }}
</div>
</div>
</div>
<div class="control-content-right">
<div
v-if="musicPlayer.musicPlayMode.value === PlayMode.REPEAT"
class="music-btn playRepeat"
@touchstart.passive="onTouchEvent"
@touchend.passive="onTouchEvent"
@click="nextPlayMode"
/>
<div
v-if="musicPlayer.musicPlayMode.value === PlayMode.SINGLE_CYCLE"
class="music-btn singleCycle"
@touchstart.passive="onTouchEvent"
@touchend.passive="onTouchEvent"
@click="nextPlayMode"
/>
<div
v-if="musicPlayer.musicPlayMode.value === PlayMode.RANDOM"
class="music-btn playRandom"
@touchstart.passive="onTouchEvent"
@touchend.passive="onTouchEvent"
@click="nextPlayMode"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { PlayMode, useMusicPlayer } from './musicPlayer/useMusicPlayer'
import { musicList } from './musicPlayer/musics'
const systemSoundMode = ref(true) // 该变量应该在store中,便于设置页面控制全局声音的开启与否
const musicPlayer = useMusicPlayer()
const audioProgressWrap = ref()
const audioProgress = ref()
/** 当前播放的音乐名 */
const currentMusicTitle = computed(() =>
musicPlayer.musicPlayingIndex.value + 1 > 0
? musicList[musicPlayer.musicPlayingIndex.value].title
: ''
)
/** 当前播放的音乐总时间 */
const currentMusicTotalTimeStr = computed(() =>
musicPlayer.musicPlayingIndex.value + 1 > 0
? musicList[musicPlayer.musicPlayingIndex.value].time
: '00:00'
)
/** 操作:切换播放模式 */
const nextPlayMode = () => {
musicPlayer.musicPlayMode.value = (musicPlayer.musicPlayMode.value + 1) % 3
switch (musicPlayer.musicPlayMode.value) {
case PlayMode.REPEAT:
console.log('循环播放')
break
case PlayMode.RANDOM:
console.log('随机播放')
break
case PlayMode.SINGLE_CYCLE:
console.log('单曲循环')
break
default:
break
}
}
/** 事件:当点击按钮时的过渡效果 */
const onTouchEvent = (event: Event) => {
const tg = event.currentTarget as HTMLElement
if (!tg) return
if (event.type === 'touchstart') {
tg.classList.add('touch')
}
if (event.type === 'touchend') {
tg.classList.remove('touch')
}
}
/** 格式化:秒数=>ss:mm */
const formatSecond = (second: number) => {
let hourStr = `${Math.floor(second / 60)}`
let secondStr = `${Math.ceil(second % 60)}`
if (hourStr.length === 1) {
hourStr = `0${hourStr}`
}
if (secondStr.length === 1) {
secondStr = `0${secondStr}`
}
return `${hourStr}:${secondStr}`
}
/** 操作:播放所选音乐 */
const switchAudio = (index: number) => {
const player = musicPlayer.musicPlayer.value
if (!systemSoundMode.value) {
window.alert('所有声音已关闭')
}
if (player?.src && player?.src.includes(musicList[index].mp3Name)) {
if (musicPlayer.musicIsPlaying.value) {
return
}
musicPlayer.playByLast()
} else {
musicPlayer.playByIndex(index)
}
}
/** 操作:上一首 */
const prev = () => {
if (!systemSoundMode.value) {
window.alert('所有声音已关闭')
}
musicPlayer.playPrev()
}
/** 操作:下一首 */
const next = () => {
if (!systemSoundMode.value) {
window.alert('所有声音已关闭')
}
musicPlayer.playNext()
}
/** 操作:播放/暂停 */
const togglePlayer = () => {
const player = musicPlayer.musicPlayer.value
if (!systemSoundMode.value) {
window.alert('所有声音已关闭')
}
if (musicPlayer.musicIsPlaying.value && player?.src) {
// 正在播放,则暂停
musicPlayer.pause()
} else if (!player?.src) {
// 未开始播放,则播放第一首
musicPlayer.playByIndex(0)
} else {
// 暂停了,则继续播放刚才的
musicPlayer.playByLast()
}
}
/** 监听:当前播放中的音乐的进度时间=>进度条变化 */
watch(
() => musicPlayer.currentTime.value,
() => {
const player = musicPlayer.musicPlayer.value
if (!audioProgressWrap.value || !audioProgress.value || !player) {
return
}
const offsetLeft =
(player.currentTime / player.duration) * audioProgressWrap.value.offsetWidth
audioProgress.value.style.width = `${offsetLeft}px`
}
)
</script>
<style lang="less" scoped>
@bottomHeight: 97px;
@controlHeight: 63px;
@controlBottom: 34px;
.music-box {
width: 100%;
height: 100%;
background-color: #141624;
position: relative;
display: flex;
flex-direction: column;
}
.music-list {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
.music-item:nth-of-type(1) {
margin-top: 7px;
}
.music-item {
padding: 12px 20px 19px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
line-height: 120%;
color: #8f9095;
.item-left {
display: flex;
align-items: center;
.item-left-title {
height: 17px;
margin-right: 10px;
}
#equalizer {
position: relative;
}
#bar1 {
animation: bar1 1.2s infinite linear;
}
#bar2 {
animation: bar2 0.8s infinite linear;
}
#bar3 {
animation: bar3 1s infinite linear;
}
#bar4 {
animation: bar4 0.7s infinite linear;
}
@keyframes bar1 {
0% {
height: 2px;
}
50% {
height: 7px;
}
100% {
height: 2px;
}
}
@keyframes bar2 {
0% {
height: 5px;
}
40% {
height: 1px;
}
80% {
height: 7px;
}
100% {
height: 5px;
}
}
@keyframes bar3 {
0% {
height: 7px;
}
50% {
height: 0;
}
100% {
height: 7px;
}
}
@keyframes bar4 {
0% {
height: 2px;
}
50% {
height: 7px;
}
100% {
height: 2px;
}
}
}
}
.music-item-active {
.item-left {
.item-left-title {
color: #3994f9;
}
}
.item-right {
color: #3994f9;
}
}
}
.music-control {
height: @bottomHeight;
padding: 0 10px;
background-color: #141624;
.control-content {
height: @controlHeight;
border-radius: 7px;
background-color: #1b1d2a;
display: flex;
align-items: center;
justify-content: space-between;
.control-content-left {
display: flex;
align-items: center;
.prev,
.pause,
.play {
margin-right: 10px;
}
.next {
margin-right: 17px;
}
}
.control-content-center {
margin-top: 1px;
flex: 1;
.center-title {
margin-bottom: 5px;
line-height: 120%;
font-size: 13px;
font-weight: 500;
color: #fff;
}
.center-progress-wrap {
width: 100%;
height: 2px;
background-color: #3e404e;
.center-progress-wrap-active {
width: 0;
height: 100%;
background-color: #3994f9;
}
}
.center-time {
height: 50%;
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.center-time-now,
.center-time-total {
font-size: 10px;
font-weight: 400;
line-height: 12px;
color: #3994f9;
}
.center-time-total {
color: #8f9095;
}
}
}
.control-content-right {
padding-left: 10px;
}
.music-btn {
width: 33px;
height: 33px;
&.prev {
background: url('../../assets/images/music/music-prev.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-prev-touch.png');
}
}
&.play {
background: url('../../assets/images/music/music-play.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-play-touch.png');
}
}
&.pause {
background: url('../../assets/images/music/music-pause.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-pause-touch.png');
}
}
&.next {
background: url('../../assets/images/music/music-next.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-next-touch.png');
}
}
&.playRepeat {
background: url('../../assets/images/music/music-repeat.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-repeat-touch.png');
}
}
&.singleCycle {
background: url('../../assets/images/music/music-single-cycle.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-single-cycle-touch.png');
}
}
&.playRandom {
background: url('../../assets/images/music/music-random.png');
background-size: 100%;
background-repeat: no-repeat;
&.touch {
background: url('../../assets/images/music/music-random-touch.png');
}
}
}
}
}
</style>
最后
觉得有用的朋友请用你的金手指点一下赞,或者评论留言一起探讨技术!