简介

记录关于自己使用 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: stringsrc: stringtime: stringmp3Name: 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-1let timer: NodeJS.Timer | null = null // 计时器---此处需要在 .eslintrc.js/.cjs 文件中配置 globals: { NodeJS: true }let source: MediaElementAudioSourceNode | null = nulllet audioCtx: AudioContext | null = nulllet gainNode: GainNode | null = nulllet audioContextAttr: string | null = nullif ('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 = vif (v < 0) {num = 0} else if (v > 100) {num = 100}defaultVolume = num / 100return defaultVolume}/** * 计时器:回调-更新显示-MP3的播放时间 */const _intervalUpdatePlayTime = () => {const player = musicPlayer.valueif (!player) returncurrentTime.value = player.currentTime}/** * 计时器:清除 */const _clearTimer = () => {if (!timer) returnclearInterval(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 = nullgainNode = nullaudioCtx = nullmusicPlayer.value = null}/** * 音乐:初始化audio与AudioContext的绑定 * 目的是为了 IOS 上能调整音量 */const _init = () => {if (!audioContextAttr) return// 先暂停已有的播放pause()// 对已创建的绑定关系进行解绑_destroyConnect()// 若在body中找得到对应的dom,则进行移除const findDom = document.getElementById('musicPlayerAudio') as HTMLAudioElementif (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) returnsource = 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.valueif (!player) returnif (v === 0) {player.muted = true} else {player.muted = false}if (!gainNode || !gainNode.gain) returngainNode.gain.value = v}/** * 音乐:播放器-暂停 */const pause = () => {const player = musicPlayer.valueif (!musicIsPlaying.value || !player) {return}musicIsPlaying.value = falseplayer.pause()_clearTimer()}/** * 音乐:播放器-播放 */const playByLast = () => {const player = musicPlayer.valueif (!player || !player.src) returnif (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.valueif (!player) {return}// 重置当前播放了的时长currentTime.value = 0// 更新要播放的下标musicPlayingIndex.value = indexif (!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].srcplayByLast()}/** * 音乐:随机播放 */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 + 1playByIndex(index)}}/** * 音乐:播放器-上一首 */const playPrev = () => {if (musicPlayMode.value === PlayMode.RANDOM) {randomPlay()} else {const index: number =musicPlayingIndex.value < 1 ? musicList.length - 1 : musicPlayingIndex.value - 1playByIndex(index)}}/** * 回调:播放结束后,下一首播放什么 */const onAudioEnded = () => {switch (musicPlayMode.value) {case PlayMode.REPEAT:playNext()breakcase PlayMode.SINGLE_CYCLE:playByIndex(musicPlayingIndex.value)breakcase PlayMode.RANDOM:randomPlay()breakdefault: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"><divv-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><svgv-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"><rectid="bar1"transform="translate(0.500000, 6.000000) rotate(180.000000) translate(-0.500000, -6.000000) "x="0"y="5"width="1"height="2px"></rect><rectid="bar2"transform="translate(3.500000, 4.500000) rotate(180.000000) translate(-3.500000, -4.500000) "x="3"y="2"width="1"height="5"></rect><rectid="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><svgv-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"><divclass="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"/><divclass="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"><divv-if="musicPlayer.musicPlayMode.value === PlayMode.REPEAT"class="music-btn playRepeat"@touchstart.passive="onTouchEvent"@touchend.passive="onTouchEvent"@click="nextPlayMode"/><divv-if="musicPlayer.musicPlayMode.value === PlayMode.SINGLE_CYCLE"class="music-btn singleCycle"@touchstart.passive="onTouchEvent"@touchend.passive="onTouchEvent"@click="nextPlayMode"/><divv-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) % 3switch (musicPlayer.musicPlayMode.value) {case PlayMode.REPEAT:console.log('循环播放')breakcase PlayMode.RANDOM:console.log('随机播放')breakcase PlayMode.SINGLE_CYCLE:console.log('单曲循环')breakdefault:break}}/** 事件:当点击按钮时的过渡效果 */const onTouchEvent = (event: Event) => {const tg = event.currentTarget as HTMLElementif (!tg) returnif (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.valueif (!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.valueif (!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.valueif (!audioProgressWrap.value || !audioProgress.value || !player) {return}const offsetLeft =(player.currentTime / player.duration) * audioProgressWrap.value.offsetWidthaudioProgress.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>

最后

觉得有用的朋友请用你的金手指点一下赞,或者评论留言一起探讨技术!