由于工作要求,需要制作一个h5页面的音乐播放器,其实如果放在原生做,效果会好很多,其实大多数app的音乐播放器也是原生做的,所以会要求你打开app再去播放音乐,但是有些特殊的情况,还是会用到h5播放器,比如说分享,把音乐分享给其他人,这个时候音乐播放器就是一个链接,这种情况下,能够保证在各个环境内都能够播放,就依赖于浏览器的功能,毕竟无论是qq,微信还是推特,社交软件上都内置了浏览器功能,这就允许分享到各个环境中。
其实音乐播放器在不同环境内是有兼容性问题的,今天我们就来一起看看开发过程中,遇到的一些问题以及如何解决吧。
autoPlay自动播放
首先需要讲清楚,本次播放器是由react编写,react版本是18+
这是产品要求的一个功能,希望页面一打开就能够实现自动播放
但是刚开始就遇到了问题,浏览器内是禁止自动播放的,由于怕打扰用户,所以浏览器都关闭了audio的自动播放功能,例如autoPlay属性
谷歌浏览器禁止播放
https://developer.chrome.com/blog/autoplay/
safari浏览器也禁止播放
https://webkit.org/blog/7734/auto-play-policy-changes-for-macos/
{/* 背景音乐+引导语播放 */}
<audio
ref={listBackgroundMusic}
preload='auto'
autoPlay
loop
className={styles.audioHidden} src={paintList.listBackgroundMusic}
/>
这样一打开页面是没有任何反应的,音频并没有开始播放。
既然属性被控制了,那使用js播放呢?毕竟audio标签有一个play方法能够调用直接播放的
play()播放
代码逻辑如下:
useEffect(() => {
listBackgroundMusic.current.load() // 首次加载音频
listBackgroundMusic.current.play() // 首次播放音频
}, [])
页面依然是没有播放的,并且控制台报错了,这个报错就是说播放失败,由于用户没有点击
仔细观看上面的文章,会发现需要用户于发生交互才能自动播放,想到这里,是不是觉得可以模拟手动点击播放呢?
模拟手动点击播放
<div ref={musicIcon} onClick={handleMusic}>musicPlay</div>
useEffect(() => {
musicIcon.current.click()
},[])
const handlePlay = () => {
if (isMusicPlay) {
setIsMusicPlay(false)
listBackgroundMusic.current.pause()
} else {
setIsMusicPlay(true)
listBackgroundMusic.current.play()
}
}
页面依旧还是不能自动播放,控制台也依旧是报错的,报错的信息跟上面一致
在移动端Ipad的报错信息,会直接显示在页面上
看来浏览器就是防止了各种各样的误操作,导致页面影响用户体验,难道这个功能就实现不了了?
酷狗音乐如何实现
当找不到办法时,就可以看看大厂是怎么实现的,比如酷狗音乐的播放能够实现自动播放吗?
https://m.kugou.com/share/?chain=7UcVd6aBfV2
移动端可以扫码看效果
在网页中打开,我们能够发现,会有一个弹出层,然后点击之后,是未播放状态的。
在安卓的微信环境打开,是这样的,不会自动播放
在安卓的默认浏览器打开是会自动播放的
到这里,我们就能够发现,即使安卓,在不同环境内,也存在兼容问题,当不能够播放时,我们能够如何解决呢?首先就是弹出层提示点击播放,然后就是不能播放的情况下,就播放状态为暂停,提示用户未开始播放。
在ios环境内就更加严格了,webkit是禁止页面自动播放的,所以ios的默认浏览器中,不会自动播放
但是在微信中,又是能够自动播放的
对比:
Android | IOS | |
---|---|---|
默认浏览器 | 能自动播放 | 不能自动播放 |
微信环境 | 不能自动播放 | 能自动播放 |
看到这里,其实音频自动播放就存在很多兼容性问题了,大厂也不能实现统一的自动播放
其实如果除了使用h5的能力,也使用原生的能力,那就不会有这么多限制了,像这种在微信内,能够自动播放的,我觉得大概率是使用到了微信的sdk
微信sdk实现自动播放
果然一查,就有这样的实现
https://juejin.cn/post/7113805660667510798
demo:
https://go.163.com/f2e/20220623_howler/index.html?type=audio
这样确实能够在h5内实现自动播放,但是缺点也很明显,就是只是在微信环境内,用户分享到其他地方打开,也就不能自动播放了
所以最后跟产品商量之后,就只能根据兼容性来看,是否自动播放了,不能自动播放的,就默认暂停状态,需要用户手动点击。
不能自动播放,怎么获取到播放失败的状态呢?
DOM调用播放的函数,会返回一个promise,如果promise的状态是reject,那就是失败的,如果播放成功了,就说resolve状态
自动播放就这样解决了~
接下来的是切歌
产品的要求自然是切换下一首之后,又自动播放了,这个时候,可就有点击事件了,不会被浏览器阻止行为了,但是仍然遇到了问题。
切歌播放被禁止
原因:
https://developer.chrome.com/blog/play-request-was-interrupted/
这两个错误,在切歌频繁的时候就会发生,发生之后,就是这首歌不播放了
情况1
Uncaught (in promise) DOMException: The play() request was interrupted by a new load request.
**
出现的原因,音乐的资源未加载完成,就开始播放了
什么时候资源才加载完成呢?
切换歌曲,显然就是更换audio的src属性值,但是这个属性值,一定就是一个路径,什么时候能够保证,这首歌曲加载完成呢?
我找到了Audio对象(MDN)
https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLAudioElement/Audio
初始化对象之后, 这个对象就加载完成了
// Handle setup when changing tracks
useUpdateEffect(() => {
audioRef.current.pause(); // 暂停音乐播放
audioRef.current = new Audio(musicList[curIndex].musicUrl); // 加载另一首音乐(使用对象加载,对象初始化就完成音频加载)
audioRef.current.play();
setIsMusicPlay(true);
}, [curIndex]);
情况2
Uncaught (in promise) DOMException: The play() request was interrupted by a call to pause().
**
出现的原因,上面链接的文章也解释了,就说play()是一个异步操作,要等播放完成操作,才能暂停播放
如果切歌频繁,这首歌还没有开始播,这首歌就要暂停了,然后就出现问题了
经历的步骤就是这样的
如何解决这个问题,文章中提到的,就不够实现我们的场景了,视频的播放和音频差不多,在promise.then中执行暂停操作,但这会有问题,我们是需要在点击之后就执行暂停上一首歌,播放下一首歌的。
import { useUpdateEffect, useMount, useThrottleFn } from 'ahooks';
// Handle setup when changing tracks
useUpdateEffect(() => {
audioRef.current = new Audio(musicList[curIndex].musicUrl); // 加载另一首音乐(使用对象加载,对象初始化就完成音频加载)
handlePlay() // 开始播放
}, [curIndex]);
const handlePlay = () => {
if (isMusicPlay) {
setIsMusicPlay(false)
audioRef.current.pause()
} else {
setIsMusicPlay(true)
const promise = audioRef.current.play()
promise.catch((err: any) => {
console.log(err)
setIsMusicPlay(false)
})
}
}
const handleNext = () => {
handlePlay()
setCurIndex(curIndex + 1)
}
<audio
ref={audioRef}
preload='auto'
loop
muted={false}
onTimeUpdate={updateTime}
onCanPlay={handleReady}
className={styles.audioHidden} src={list[curIndex]}
/>
情况3
获取歌曲进度,音乐播放被停止
这个情况呢,是由于audio.currentTime为NaN了
所以,如果出现NaN的情况,就不要赋值给currentTime
const updateTime = (e: SyntheticEvent<HTMLAudioElement, Event>) => {
setCurrentTime(Math.trunc(num))
}
const changeProgressValue = (val: number) => {
const num = Math.floor(val / 100 * audioRef.current.duration)
if (!isNaN(num)) {
audioRef.current.currentTime = num
}
}
<audio
ref={audioRef}
preload='auto'
loop
muted={false}
onTimeUpdate={updateTime}
onCanPlay={handleReady}
className={styles.audioHidden} src={list[curIndex]}
/>
<MusicSlider
className={styles.songSlider}
defaultValue={currentTime && duration ? currentTime / duration * 100 : 0}
onAfterChange={changeProgressValue}
value={currentTime && duration ? currentTime / duration * 100 : 0}
/>
另外,由于使用Audio对象之后,audioRef.current对象发生变化,所以对应的timeupdate和canplay两个事件需要重新绑定
不然这两个事件在切歌之后将不会正常进行
// Handle setup when changing tracks
useUpdateEffect(() => {
isMusicPlay && audioRef.current.pause(); // 暂停音乐播放
audioRef.current = new Audio(musicList[curIndex].musicUrl); // 加载另一首音乐(使用对象加载,对象初始化就完成音频加载)
audioRef.current.removeEventListener('timeupdate', () => setCurrentTime(0)) // 取消上一首歌曲的绑定事件
audioRef.current.removeEventListener('canplay', () => setDuration(0))
audioRef.current.loop = true
audioRef.current.addEventListener('timeupdate', updateTime) // 添加这一首歌曲的绑定事件(否则当前歌曲不会触发此事件)
audioRef.current.addEventListener('canplay', handleReady)
handlePlay();
}, [curIndex]);
页面移除,播放未停止
这个bug,完全就是我没有看完文档所导致的,罪魁祸首就说Audio这个对象
https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLAudioElement/Audio
audio这个对象都移除了,但是new的对象依然在内存中,不会销毁
所以就又回到了上一个问题的情况1
何时确保音乐资源加载完成再播放?
找一下audio标签的事件
https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/audio#事件
这个事件就能保证资源加载完成了,正好我在这个事件里面获取歌曲时长
const handleReady = () => {
// @ts-ignore
const promise = audioRef.current.play()
promise.catch((err: any) => {
console.log(err)
setIsMusicPlay(false)
})
// @ts-ignore
setDuration(audioRef.current.duration)
}
有这个事件执行了,就不需要点击执行播放事件了
最终的播放逻辑代码
import { useUpdateEffect, useMount, useThrottleFn, useDocumentVisibility } from 'ahooks';
...
export default function MusicPlay(props: IMusicProps) {
const router = useRouter();
const [musicList, setMusicList] = useState<IDetailProp[]>(dataList)
const [currentTime, setCurrentTime] = useState<null|number>(null)
const [duration, setDuration] = useState<null|number>(null)
const [curIndex, setCurIndex] = useState(0)
const [isMusicPlay, setIsMusicPlay] = useState(false)
const [isShowImgView, setShowImgView] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
// 分享海报弹窗
const [posterPopupShow, setPosterPopupShow] = useState(false);
const documentVisibility = useDocumentVisibility()
useMount(() => {
audioRef.current.addEventListener('timeupdate', updateTime) // 添加这一首歌曲的绑定事件(否则当前歌曲不会触发此事件)
audioRef.current.addEventListener('canplay', handleReadyInit)
setDuration(audioRef.current.duration)
});
useEffect(() => {
if (documentVisibility === 'hidden') {
setIsMusicPlay(false)
audioRef.current && audioRef.current.pause()
}
}, [documentVisibility])
// Handle setup when changing tracks
useUpdateEffect(() => {
audioRef.current.pause(); // 暂停音乐播放
// 不使用对象,因为对象在内存中,页面卸载时,无法移除
// audioRef.current = new Audio(musicList[curIndex].musicUrl); // 加载另一首音乐(使用对象加载,对象初始化就完成音频加载)
audioRef.current.removeEventListener('timeupdate', () => setCurrentTime(0)) // 取消上一首歌曲的绑定事件
audioRef.current.removeEventListener('canplay', () => setDuration(0))
// 重新绑定事件和属性,因为歌曲变化,歌曲的时长发生改变
audioRef.current.loop = true
audioRef.current.addEventListener('timeupdate', updateTime) // 添加这一首歌曲的绑定事件(否则当前歌曲不会触发此事件)
audioRef.current.addEventListener('canplay', handleReady)
setIsMusicPlay(true);
}, [curIndex]);
const { run } = useThrottleFn(
(num) => {
setCurrentTime(Math.trunc(num))
},
{ wait: 1000 },
);
const updateTime = (e: SyntheticEvent<HTMLAudioElement, Event>) => {
run(e.target.currentTime) // 节流更新
}
const handleReadyInit = () => {
setDuration(audioRef.current.duration)
}
const handleReady = () => {
const promise = audioRef.current.play()
promise.catch((err: any) => {
console.log(err)
setIsMusicPlay(false)
})
setDuration(audioRef.current.duration)
}
const handlePlay = () => {
if (isMusicPlay) {
setIsMusicPlay(false)
audioRef.current.pause()
} else {
setIsMusicPlay(true)
const promise = audioRef.current.play()
promise.catch((err: any) => {
console.log(err)
setIsMusicPlay(false)
})
}
}
const getMusicList = async(id: number, direction: number, isInit: boolean) => {
showLoading(true)
// 1-向前,2-向后
const { data } = await musicListApi({
baseId: id,
direction: direction,
size: 10
})
if (direction == 2) {
setCurIndex(0)
} else {
setCurIndex(data.length - 1)
}
setMusicList(data)
showLoading(false)
}
const changeProgressValue = (val: number) => {
const num = Math.floor(val / 100 * audioRef.current.duration)
if (!isNaN(num)) {
audioRef.current.currentTime = num
}
}
const handleNext = () => {
if (musicList.length < 10) {
if (curIndex < musicList.length - 1) {
setCurIndex(curIndex + 1)
} else {
setCurIndex(0)
}
} else {
if (curIndex < musicList.length - 1) {
setCurIndex(curIndex + 1)
} else {
getMusicList(musicList[musicList.length - 1].id, 2, false)
}
}
}
const handlePrev = () => {
if(musicList.length < 10) {
if (curIndex - 1 < 0) {
setCurIndex(musicList.length - 1)
} else {
setCurIndex(curIndex - 1)
}
} else {
if (curIndex - 1 < 0) {
getMusicList(musicList[0].id, 1, false)
} else {
setCurIndex(curIndex - 1)
}
}
}
return <>...
<audio
ref={audioRef}
preload='auto'
loop
className={styles.audioHidden} src={musicList[curIndex].musicUrl || ''}
/>
</>
}
这里是每次请求接口10首歌曲,如果歌曲小于10首,就循环播放.
今天的分享就到这里, 感谢阅读~