音频播放
根据 MDN 文档,音频自动播放需满足以下任一条件:
- 音频被静音或音量为 0。
- 用户已与页面发生交互(点击、触摸、按键等)。
- 站点已列入浏览器自动播放白名单。
实际开发中通常采用第②种方案(交互后播放)。
注意点
- 在 iOS 中,播放音频必须先获取麦克风权限,再获取音频播放权限,否则无法正常播放。
- 在 iOS 中,通过内网 IP 地址 访问网页时无法播放音频,原因是该环境下无法获取麦克风权限。
- 解决办法:通过内网穿透将内网 IP 映射为公网域名访问。
- 在 PC 中,可通过预检测
AudioContext
状态,若为running
,则认为站点已列入浏览器自动播放白名单 ;- 在 H5 不能预检测
AudioContext
状态。
- 在 H5 不能预检测
- 在 iOS 中,即使用户已与页面发生交互,
AudioContext
状态也不会直接变成running
,仍需如下操作:- 调用
resume
方法并成功返回;注意首次调用可能 (暂未找到规律)在resume
方法调用 一直卡住,需做超时处理。 - 等待 50 ms。
- 最终校验
AudioContext
是否为running
状态。
- 调用
交互实现

代码实现
typescript
/**
* 判断当前环境是否为 iOS 设备。
*
* @returns 如果是iOS设备,返回true;否则返回false。
*/
function isIos(): boolean {
const userAgent = navigator.userAgent.toLowerCase()
return /iphone|ipad|ipod/.test(userAgent)
}
/**
* 判断当前环境是否为 Android 设备。
*
* @returns 如果是Android设备,返回true;否则返回false。
*/
function isAndroid(): boolean {
const userAgent = navigator.userAgent.toLowerCase()
return /android/.test(userAgent)
}
enum AudioPermissionErrorType {
NO_USER_INTERACTION_ERROR = 'NoUserInteractionError',
AUTOPLAY_BLOCKED = 'AutoplayBlocked',
AUTOPLAY_UNKNOWN_ERROR = 'AutoplayUnknownError',
SECURE_CONTEXT_ERROR = 'SecureContextError',
PERMISSION_DENIED_PERMANENTLY = 'PermissionDeniedPermanently',
NOT_ALLOWED_ERROR = 'NotAllowedError',
MEDIA_DEVICES_API_UNAVAILABLE = 'MediaDevicesApiUnavailable',
NOT_FOUND_ERROR = 'NotFoundError',
NOT_READABLE_ERROR = 'NotReadableError',
OVERCONSTRAINED_ERROR = 'OverconstrainedError',
SECURITY_ERROR = 'SecurityError',
MICROPHONE_UNKNOWN_ERROR = 'MicrophoneUnknownError',
MICROPHONE_TIMEOUT_ERROR = 'MicrophoneTimeoutError',
}
class AudioPermissionError extends Error {
public name: AudioPermissionErrorType
private constructor(name: AudioPermissionErrorType, message: string) {
super(message)
this.name = name
}
public static create(
name: AudioPermissionErrorType,
message?: string,
): AudioPermissionError {
const codeMessageMap = {
[AudioPermissionErrorType.NO_USER_INTERACTION_ERROR]: '需要用户交互才能播放音频',
[AudioPermissionErrorType.AUTOPLAY_BLOCKED]: '自动播放被浏览器阻止',
[AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR]: '未知自动播放错误',
[AudioPermissionErrorType.SECURE_CONTEXT_ERROR]: '麦克风权限需要在安全上下文(HTTPS)中使用',
[AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY]: '音频权限已被永久拒绝,需在浏览器设置中开启麦克风权限',
[AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE]: 'MediaDevices API不可用',
[AudioPermissionErrorType.NOT_ALLOWED_ERROR]: '麦克风权限被拒绝',
[AudioPermissionErrorType.NOT_READABLE_ERROR]: '音频设备被其他应用程序占用',
[AudioPermissionErrorType.NOT_FOUND_ERROR]: '未找到音频输入设备',
[AudioPermissionErrorType.SECURITY_ERROR]: '安全策略阻止访问麦克风',
[AudioPermissionErrorType.OVERCONSTRAINED_ERROR]: '音频设备不满足指定的约束条件',
[AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR]: '未知麦克风权限错误',
[AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR]: '获取麦克风权限超时,请检查设备权限设置或重试',
}
return new AudioPermissionError(name, message || codeMessageMap[name as keyof typeof codeMessageMap])
}
}
/**
* 音频播放权限管理类
*
* 根据MDN文档,音频播放权限需要满足以下条件之一:
* 1. 音频被静音或音量为0
* 2. 用户已与网站产生交互(点击、触摸、按键等)
* 3. 网站已被加入自动播放白名单
* 4. 通过Permissions Policy授权
*
* 跨平台实现策略:
* - PC端:预检测AudioContext状态,如果suspended则监听用户交互
* - iOS Safari:必须在用户交互事件的调用栈中调用resume(),需要50ms延迟确保状态变更
* - Android:直接在用户交互后检测AudioContext状态
*
*
*
* 实现原理:
* PC:
* 1.在PC端首先预检测AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则需要监听用户交互事件,
* 2.用户交互事件触发后,再检测AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则抛出异常。
*
* IOS:
* 1.用户交互事件触发后,再检测AudioContext状态:
* 如果状态是 "suspended" 则需要调用 resume 方法,并且需要延迟 50ms,
* 如果状态不是 "suspended" 状态进行下一步判断,
* 2.获取AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则抛出异常。
*
* Android:
* 1.用户交互事件触发后,再检测AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则抛出异常。
*/
class AudioPlaybackPermission {
// 用户是否已产生交互行为
private userInteracted: boolean = false
private executeContext!: AudioPermission
constructor() {
this.initPlaybackPermission()
}
/**
* 初始化播放权限检测
*/
private initPlaybackPermission(): void {
// 根据MDN文档,PC平台可以预检测AudioContext状态
if (!isIos() && !isAndroid()) {
this.checkInitialAudioContextState().catch(() => {
this.setupUserInteractionListeners()
})
}
else {
this.setupUserInteractionListeners()
}
}
/**
* 检测初始AudioContext状态(仅PC平台)
*/
private async checkInitialAudioContextState(): Promise<void> {
const testContext = new (window.AudioContext || (window as any).webkitAudioContext)()
if (testContext.state === 'running') {
this.userInteracted = true
}
await testContext.close()
if (this.userInteracted) {
return Promise.resolve()
}
else {
// eslint-disable-next-line prefer-promise-reject-errors
return Promise.reject()
}
}
/**
* 设置用户交互监听器
*/
private setupUserInteractionListeners(): void {
const interactionEvents = [
'click',
'touchstart',
'touchend',
'keydown',
'mousedown',
'pointerdown',
]
const handleInteraction = (): void => {
this.userInteracted = true
// 清理所有事件监听器
interactionEvents.forEach((eventType: string) => {
document.removeEventListener(eventType, handleInteraction)
})
}
interactionEvents.forEach((eventType) => {
document.addEventListener(eventType, handleInteraction, { passive: true, once: true })
})
}
/**
* 请求播放权限
* 根据Web Audio API最佳实践实现
*/
public async requestPlaybackPermission(): Promise<void> {
let audioContext: AudioContext | null = null
try {
// 检查用户交互状态
if (!this.userInteracted) {
throw AudioPermissionError.create(AudioPermissionErrorType.NO_USER_INTERACTION_ERROR)
}
// iOS平台特殊处理:先申请麦克风权限
if (isIos() && this.executeContext) {
await this.executeContext.requestMicrophonePermission()
}
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
// 根据MDN文档,检查AudioContext状态并处理
if (isIos() && audioContext.state === 'suspended') {
// iOS Safari特殊处理:必须在用户交互的调用栈中resume
await Promise.race([
audioContext.resume(),
new Promise((_, reject) =>
setTimeout(() => reject(AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)), 3000),
),
])
// iOS可能需要短暂延迟确保状态变更
await new Promise(resolve => setTimeout(resolve, 50))
}
// 验证最终状态
if (audioContext.state !== 'running') {
throw AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)
}
return Promise.resolve()
}
catch (error) {
if (error instanceof AudioPermissionError) {
return Promise.reject(error)
}
const unknownError = AudioPermissionError.create(
AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR,
error instanceof Error ? error.message : undefined,
)
return Promise.reject(unknownError)
}
finally {
if (audioContext && audioContext.state !== 'closed') {
try {
await audioContext.close()
}
catch { }
}
}
}
/**
* 执行上下文设置
* @param context 执行上下文
*/
setExecuteContext(context: AudioPermission): void {
this.executeContext = context
}
}
/**
* 麦克风权限管理类
*
* 根据MDN文档,getUserMedia需要满足:
* 1. 安全上下文(HTTPS)
* 2. 用户明确授权
* 3. 顶级文档上下文或通过Permissions Policy授权的iframe
*
*
* 权限状态说明:
* - granted: 用户已授权,可直接访问麦克风
* - denied: 用户已拒绝,需要用户手动在浏览器设置中重新开启
* - prompt: 首次访问,会弹出授权对话框
*
*
* 平台差异:
* - 桌面端:通过浏览器原生授权对话框处理
* - iOS Safari:权限被拒绝后,需要用户在系统设置中重新开启
* - Android Chrome:权限处理与桌面端类似,但某些版本可能有缓存问题
*
*
* 注意事项:
* 1. 浏览器会永久缓存用户的权限决定
* 2. 在非HTTPS环境下(除localhost外)会直接失败
* 3. 某些浏览器在隐私模式下可能有不同行为
* 4. iframe中使用需要正确配置Permissions Policy
*
*
* 实现原理:
* - PC平台:
* 1. 通过授权弹框申请权限:
* 如果用户拒绝了权限,会抛出异常
* 如果用户点击了允许,会返回成功
* 2.第二次申请权限,会根据用户第一次申请权限的结果:
* 如果用户第一次申请权限失败,会抛出异常
* 如果用户第一次申请权限成功,会返回成功
* - 移动端:
* 1. 通过授权弹框申请权限:
* 如果用户拒绝了权限,会抛出异常
* 如果用户点击了允许,会返回成功
* 2.第二次申请权限,会根据用户第一次申请权限的结果:
* 如果用户第一次申请权限失败,会抛出异常
* 如果用户第一次申请权限成功,会返回成功
*
* iOS平台:
* 1. 通过授权弹框申请权限:
* 如果用户拒绝了权限,会抛出异常
* 如果用户点击了允许,会返回成功
* 2.第二次申请权限,会根据用户第一次申请权限的结果:
* 如果用户第一次申请权限失败,会抛出异常
* 如果用户第一次申请权限成功,会返回成功
*
*
*/
class MicrophonePermission {
private microphonePermissionGranted: boolean = false
private permissionDeniedPermanently: boolean = false
/**
* 检查是否在安全上下文中
*/
private isSecureContext(): boolean {
return window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost'
}
/**
* 带超时的getUserMedia调用
* @param constraints - 媒体约束
* @param timeoutMs - 超时时间(毫秒)
* @returns Promise<MediaStream>
*/
private async getUserMediaWithTimeout(
constraints: MediaStreamConstraints,
timeoutMs: number = 10000,
): Promise<MediaStream> {
return new Promise((resolve, reject) => {
// 设置超时定时器
const timeoutId = setTimeout(() => {
reject(AudioPermissionError.create(
AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR,
))
}, timeoutMs)
// 调用getUserMedia
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
clearTimeout(timeoutId)
resolve(stream)
})
.catch((error) => {
clearTimeout(timeoutId)
reject(error)
})
})
}
/**
* 请求麦克风权限
* 根据MediaDevices.getUserMedia规范实现
*/
public async requestMicrophonePermission(): Promise<void> {
try {
// 检查安全上下文
if (!this.isSecureContext()) {
throw AudioPermissionError.create(
AudioPermissionErrorType.SECURE_CONTEXT_ERROR,
)
}
// 检查是否已被永久拒绝
if (this.permissionDeniedPermanently) {
throw AudioPermissionError.create(AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY)
}
// 如果权限已获取,直接返回
if (this.microphonePermissionGranted) {
return Promise.resolve()
}
// 检查MediaDevices API可用性
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw AudioPermissionError.create(
AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE,
)
}
// 请求麦克风权限
const stream = await this.getUserMediaWithTimeout({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
// 权限获取成功
this.microphonePermissionGranted = true
// 立即停止媒体流,我们只是测试权限
stream.getTracks().forEach((track) => {
track.stop()
})
return Promise.resolve()
}
catch (error) {
this.microphonePermissionGranted = false
if (error instanceof AudioPermissionError) {
return Promise.reject(error)
}
if (error instanceof DOMException) {
let errorType: AudioPermissionErrorType
switch (error.name) {
case 'NotFoundError':
errorType = AudioPermissionErrorType.NOT_FOUND_ERROR
break
case 'NotAllowedError':
this.permissionDeniedPermanently = true
errorType = AudioPermissionErrorType.NOT_ALLOWED_ERROR
break
case 'NotReadableError':
errorType = AudioPermissionErrorType.NOT_READABLE_ERROR
break
case 'OverconstrainedError':
errorType = AudioPermissionErrorType.OVERCONSTRAINED_ERROR
break
case 'SecurityError':
errorType = AudioPermissionErrorType.SECURITY_ERROR
break
default:
errorType = AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR
}
const audioError = AudioPermissionError.create(errorType)
return Promise.reject(audioError)
}
const unknownError = AudioPermissionError.create(
AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR,
error instanceof Error ? error.message : undefined,
)
return Promise.reject(unknownError)
}
}
/**
* 检查麦克风权限状态
*/
public isMicrophonePermissionGranted(): boolean {
return this.microphonePermissionGranted
}
}
/**
* 统一的音频权限管理类
*/
class AudioPermission {
public playbackPermission: AudioPlaybackPermission
public microphonePermission: MicrophonePermission
constructor() {
this.playbackPermission = new AudioPlaybackPermission()
this.microphonePermission = new MicrophonePermission()
this.playbackPermission.setExecuteContext(this)
}
/**
* 请求音频播放权限
*/
public async requestPlaybackPermission(): Promise<void> {
return this.playbackPermission.requestPlaybackPermission()
}
/**
* 请求麦克风权限
*/
public async requestMicrophonePermission(): Promise<void> {
return this.microphonePermission.requestMicrophonePermission()
}
/**
* 检查麦克风权限状态
*/
public isMicrophonePermissionGranted(): boolean {
return this.microphonePermission.isMicrophonePermissionGranted()
}
}
let audioPermissionInstance: AudioPermission | null = null
/**
* 创建或获取音频权限管理实例
*/
function createAudioPermission(): AudioPermission {
if (!audioPermissionInstance) {
audioPermissionInstance = new AudioPermission()
}
return audioPermissionInstance
}
export default createAudioPermission
export { AudioPermission, AudioPermissionError, AudioPermissionErrorType, AudioPlaybackPermission, MicrophonePermission }
js
import createAudioPermission from "./create-audio-permission"
const audioPermissionInstance = createAudioPermission()
const palyButton = document.createElement('button')
palyButton.innerHTML = '浏览器播放'
palyButton.addEventListener('click', () => {
// 获取音频播放权限
audioPermissionInstance.requestPlaybackPermission().then(() => {
console.log('播放权限获取成功')
}).catch((error) => {
console.log(error.message)
})
})
document.body.appendChild(palyButton)
在 iOS中,获取音频播放权限,必须先获取麦克风权限
实战效果

用户初始进入时,因为并没有和网页产生交互,所以是静音状态。
-
在 PC、Android中,当用户点击"打开声音",就等于和网页产生了点击交互,也就能顺利获取音频播放权限。
-
在 iOS 中,用户点击"打开声音",首先需要获取浏览器麦克风权限,才能顺利获取音频播放权限。

麦克风
根据 MDN 文档,调用 navigator.mediaDevices.getUserMedia
需同时满足:
- 安全上下文(HTTPS)。
- 用户明确授权。
- 顶级文档上下文,或通过
Permissions-Policy
授权的<iframe>
。
前两条必须同时满足;若页面嵌入 <iframe>
,还需满足第三条。
注意点
- 在 iOS 中,通过内网 IP 地址 访问网页时无法获取麦克风权限。
- 解决办法:通过内网穿透将内网 IP 映射为公网域名访问。
- 浏览器会永久缓存用户的麦克风权限决定;若用户首次弹框即拒绝,后续将默认拒绝。
- 在小米安卓手机中,如果用户拒绝了麦克风权限,后续调用 navigator.mediaDevices.getUserMedia 一直卡住,需做超时处理。
交互实现

代码实现
typescript
/**
* 判断当前环境是否为 iOS 设备。
*
* @returns 如果是iOS设备,返回true;否则返回false。
*/
function isIos(): boolean {
const userAgent = navigator.userAgent.toLowerCase()
return /iphone|ipad|ipod/.test(userAgent)
}
/**
* 判断当前环境是否为 Android 设备。
*
* @returns 如果是Android设备,返回true;否则返回false。
*/
function isAndroid(): boolean {
const userAgent = navigator.userAgent.toLowerCase()
return /android/.test(userAgent)
}
enum AudioPermissionErrorType {
NO_USER_INTERACTION_ERROR = 'NoUserInteractionError',
AUTOPLAY_BLOCKED = 'AutoplayBlocked',
AUTOPLAY_UNKNOWN_ERROR = 'AutoplayUnknownError',
SECURE_CONTEXT_ERROR = 'SecureContextError',
PERMISSION_DENIED_PERMANENTLY = 'PermissionDeniedPermanently',
NOT_ALLOWED_ERROR = 'NotAllowedError',
MEDIA_DEVICES_API_UNAVAILABLE = 'MediaDevicesApiUnavailable',
NOT_FOUND_ERROR = 'NotFoundError',
NOT_READABLE_ERROR = 'NotReadableError',
OVERCONSTRAINED_ERROR = 'OverconstrainedError',
SECURITY_ERROR = 'SecurityError',
MICROPHONE_UNKNOWN_ERROR = 'MicrophoneUnknownError',
MICROPHONE_TIMEOUT_ERROR = 'MicrophoneTimeoutError',
}
class AudioPermissionError extends Error {
public name: AudioPermissionErrorType
private constructor(name: AudioPermissionErrorType, message: string) {
super(message)
this.name = name
}
public static create(
name: AudioPermissionErrorType,
message?: string,
): AudioPermissionError {
const codeMessageMap = {
[AudioPermissionErrorType.NO_USER_INTERACTION_ERROR]: '需要用户交互才能播放音频',
[AudioPermissionErrorType.AUTOPLAY_BLOCKED]: '自动播放被浏览器阻止',
[AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR]: '未知自动播放错误',
[AudioPermissionErrorType.SECURE_CONTEXT_ERROR]: '麦克风权限需要在安全上下文(HTTPS)中使用',
[AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY]: '音频权限已被永久拒绝,需在浏览器设置中开启麦克风权限',
[AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE]: 'MediaDevices API不可用',
[AudioPermissionErrorType.NOT_ALLOWED_ERROR]: '麦克风权限被拒绝',
[AudioPermissionErrorType.NOT_READABLE_ERROR]: '音频设备被其他应用程序占用',
[AudioPermissionErrorType.NOT_FOUND_ERROR]: '未找到音频输入设备',
[AudioPermissionErrorType.SECURITY_ERROR]: '安全策略阻止访问麦克风',
[AudioPermissionErrorType.OVERCONSTRAINED_ERROR]: '音频设备不满足指定的约束条件',
[AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR]: '未知麦克风权限错误',
[AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR]: '获取麦克风权限超时,请检查设备权限设置或重试',
}
return new AudioPermissionError(name, message || codeMessageMap[name as keyof typeof codeMessageMap])
}
}
/**
* 音频播放权限管理类
*
* 根据MDN文档,音频播放权限需要满足以下条件之一:
* 1. 音频被静音或音量为0
* 2. 用户已与网站产生交互(点击、触摸、按键等)
* 3. 网站已被加入自动播放白名单
* 4. 通过Permissions Policy授权
*
* 跨平台实现策略:
* - PC端:预检测AudioContext状态,如果suspended则监听用户交互
* - iOS Safari:必须在用户交互事件的调用栈中调用resume(),需要50ms延迟确保状态变更
* - Android:直接在用户交互后检测AudioContext状态
*
* 自动播放策略限制:
* - Chrome 66+:需要用户交互或MEI(Media Engagement Index)足够高
* - Safari:严格要求用户交互,AudioContext创建时默认为suspended状态
* - Firefox:相对宽松,但仍会阻止明显的自动播放行为
*
*
*
* 实现原理:
* PC:
* 1.在PC端首先预检测AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则需要监听用户交互事件,
* 2.用户交互事件触发后,再检测AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则抛出异常。
*
* IOS:
* 1.用户交互事件触发后,再检测AudioContext状态:
* 如果状态是 "suspended" 则需要调用 resume 方法,并且需要延迟 50ms,
* 如果状态不是 "suspended" 状态进行下一步判断,
* 2.获取AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则抛出异常。
*
* Android:
* 1.用户交互事件触发后,再检测AudioContext状态:
* 如果状态是 "running" 状态则可以播放音频,
* 如果状态不是 "running" 状态则抛出异常。
*/
class AudioPlaybackPermission {
// 用户是否已产生交互行为
private userInteracted: boolean = false
private executeContext!: AudioPermission
constructor() {
this.initPlaybackPermission()
}
/**
* 初始化播放权限检测
*/
private initPlaybackPermission(): void {
// 根据MDN文档,PC平台可以预检测AudioContext状态
if (!isIos() && !isAndroid()) {
this.checkInitialAudioContextState().catch(() => {
this.setupUserInteractionListeners()
})
}
else {
this.setupUserInteractionListeners()
}
}
/**
* 检测初始AudioContext状态(仅PC平台)
*/
private async checkInitialAudioContextState(): Promise<void> {
const testContext = new (window.AudioContext || (window as any).webkitAudioContext)()
if (testContext.state === 'running') {
this.userInteracted = true
}
await testContext.close()
if (this.userInteracted) {
return Promise.resolve()
}
else {
// eslint-disable-next-line prefer-promise-reject-errors
return Promise.reject()
}
}
/**
* 设置用户交互监听器
*/
private setupUserInteractionListeners(): void {
const interactionEvents = [
'click',
'touchstart',
'touchend',
'keydown',
'mousedown',
'pointerdown',
]
const handleInteraction = (): void => {
this.userInteracted = true
// 清理所有事件监听器
interactionEvents.forEach((eventType: string) => {
document.removeEventListener(eventType, handleInteraction)
})
}
interactionEvents.forEach((eventType) => {
document.addEventListener(eventType, handleInteraction, { passive: true, once: true })
})
}
/**
* 请求播放权限
* 根据Web Audio API最佳实践实现
*/
public async requestPlaybackPermission(): Promise<void> {
let audioContext: AudioContext | null = null
try {
// 检查用户交互状态
if (!this.userInteracted) {
throw AudioPermissionError.create(AudioPermissionErrorType.NO_USER_INTERACTION_ERROR)
}
// iOS平台特殊处理:先申请麦克风权限
if (isIos() && this.executeContext) {
await this.executeContext.requestMicrophonePermission()
}
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
// 根据MDN文档,检查AudioContext状态并处理
if (isIos() && audioContext.state === 'suspended') {
// iOS Safari特殊处理:必须在用户交互的调用栈中resume
await Promise.race([
audioContext.resume(),
new Promise((_, reject) =>
setTimeout(() => reject(AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)), 3000),
),
])
// iOS可能需要短暂延迟确保状态变更
await new Promise(resolve => setTimeout(resolve, 50))
}
// 验证最终状态
if (audioContext.state !== 'running') {
throw AudioPermissionError.create(AudioPermissionErrorType.AUTOPLAY_BLOCKED)
}
return Promise.resolve()
}
catch (error) {
if (error instanceof AudioPermissionError) {
return Promise.reject(error)
}
const unknownError = AudioPermissionError.create(
AudioPermissionErrorType.AUTOPLAY_UNKNOWN_ERROR,
error instanceof Error ? error.message : undefined,
)
return Promise.reject(unknownError)
}
finally {
if (audioContext && audioContext.state !== 'closed') {
try {
await audioContext.close()
}
catch { }
}
}
}
/**
* 执行上下文设置
* @param context 执行上下文
*/
setExecuteContext(context: AudioPermission): void {
this.executeContext = context
}
}
/**
* 麦克风权限管理类
*
* 根据MDN文档,getUserMedia需要满足:
* 1. 安全上下文(HTTPS)
* 2. 用户明确授权
* 3. 顶级文档上下文或通过Permissions Policy授权的iframe
*
*
* 权限状态说明:
* - granted: 用户已授权,可直接访问麦克风
* - denied: 用户已拒绝,需要用户手动在浏览器设置中重新开启
* - prompt: 首次访问,会弹出授权对话框
*
*
* 平台差异:
* - 桌面端:通过浏览器原生授权对话框处理
* - iOS Safari:权限被拒绝后,需要用户在系统设置中重新开启
* - Android Chrome:权限处理与桌面端类似,但某些版本可能有缓存问题
*
*
* 注意事项:
* 1. 浏览器会永久缓存用户的权限决定
* 2. 在非HTTPS环境下(除localhost外)会直接失败
* 3. 某些浏览器在隐私模式下可能有不同行为
* 4. iframe中使用需要正确配置Permissions Policy
*
*
* 实现原理:
* - PC平台:
* 1. 通过授权弹框申请权限:
* 如果用户拒绝了权限,会抛出异常
* 如果用户点击了允许,会返回成功
* 2.第二次申请权限,会根据用户第一次申请权限的结果:
* 如果用户第一次申请权限失败,会抛出异常
* 如果用户第一次申请权限成功,会返回成功
* - 移动端:
* 1. 通过授权弹框申请权限:
* 如果用户拒绝了权限,会抛出异常
* 如果用户点击了允许,会返回成功
* 2.第二次申请权限,会根据用户第一次申请权限的结果:
* 如果用户第一次申请权限失败,会抛出异常
* 如果用户第一次申请权限成功,会返回成功
*
* iOS平台:
* 1. 通过授权弹框申请权限:
* 如果用户拒绝了权限,会抛出异常
* 如果用户点击了允许,会返回成功
* 2.第二次申请权限,会根据用户第一次申请权限的结果:
* 如果用户第一次申请权限失败,会抛出异常
* 如果用户第一次申请权限成功,会返回成功
*
*
*/
class MicrophonePermission {
private microphonePermissionGranted: boolean = false
private permissionDeniedPermanently: boolean = false
/**
* 检查是否在安全上下文中
*/
private isSecureContext(): boolean {
return window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost'
}
/**
* 带超时的getUserMedia调用
* @param constraints - 媒体约束
* @param timeoutMs - 超时时间(毫秒)
* @returns Promise<MediaStream>
*/
private async getUserMediaWithTimeout(
constraints: MediaStreamConstraints,
timeoutMs: number = 10000,
): Promise<MediaStream> {
return new Promise((resolve, reject) => {
// 设置超时定时器
const timeoutId = setTimeout(() => {
reject(AudioPermissionError.create(
AudioPermissionErrorType.MICROPHONE_TIMEOUT_ERROR,
))
}, timeoutMs)
// 调用getUserMedia
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) => {
clearTimeout(timeoutId)
resolve(stream)
})
.catch((error) => {
clearTimeout(timeoutId)
reject(error)
})
})
}
/**
* 请求麦克风权限
* 根据MediaDevices.getUserMedia规范实现
*/
public async requestMicrophonePermission(): Promise<void> {
try {
// 检查安全上下文
if (!this.isSecureContext()) {
throw AudioPermissionError.create(
AudioPermissionErrorType.SECURE_CONTEXT_ERROR,
)
}
// 检查是否已被永久拒绝
if (this.permissionDeniedPermanently) {
throw AudioPermissionError.create(AudioPermissionErrorType.PERMISSION_DENIED_PERMANENTLY)
}
// 如果权限已获取,直接返回
if (this.microphonePermissionGranted) {
return Promise.resolve()
}
// 检查MediaDevices API可用性
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw AudioPermissionError.create(
AudioPermissionErrorType.MEDIA_DEVICES_API_UNAVAILABLE,
)
}
// 请求麦克风权限
const stream = await this.getUserMediaWithTimeout({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
// 权限获取成功
this.microphonePermissionGranted = true
// 立即停止媒体流,我们只是测试权限
stream.getTracks().forEach((track) => {
track.stop()
})
return Promise.resolve()
}
catch (error) {
this.microphonePermissionGranted = false
if (error instanceof AudioPermissionError) {
return Promise.reject(error)
}
if (error instanceof DOMException) {
let errorType: AudioPermissionErrorType
switch (error.name) {
case 'NotFoundError':
errorType = AudioPermissionErrorType.NOT_FOUND_ERROR
break
case 'NotAllowedError':
this.permissionDeniedPermanently = true
errorType = AudioPermissionErrorType.NOT_ALLOWED_ERROR
break
case 'NotReadableError':
errorType = AudioPermissionErrorType.NOT_READABLE_ERROR
break
case 'OverconstrainedError':
errorType = AudioPermissionErrorType.OVERCONSTRAINED_ERROR
break
case 'SecurityError':
errorType = AudioPermissionErrorType.SECURITY_ERROR
break
default:
errorType = AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR
}
const audioError = AudioPermissionError.create(errorType)
return Promise.reject(audioError)
}
const unknownError = AudioPermissionError.create(
AudioPermissionErrorType.MICROPHONE_UNKNOWN_ERROR,
error instanceof Error ? error.message : undefined,
)
return Promise.reject(unknownError)
}
}
/**
* 检查麦克风权限状态
*/
public isMicrophonePermissionGranted(): boolean {
return this.microphonePermissionGranted
}
}
/**
* 统一的音频权限管理类
*/
class AudioPermission {
public playbackPermission: AudioPlaybackPermission
public microphonePermission: MicrophonePermission
constructor() {
this.playbackPermission = new AudioPlaybackPermission()
this.microphonePermission = new MicrophonePermission()
this.playbackPermission.setExecuteContext(this)
}
/**
* 请求音频播放权限
*/
public async requestPlaybackPermission(): Promise<void> {
return this.playbackPermission.requestPlaybackPermission()
}
/**
* 请求麦克风权限
*/
public async requestMicrophonePermission(): Promise<void> {
return this.microphonePermission.requestMicrophonePermission()
}
/**
* 检查麦克风权限状态
*/
public isMicrophonePermissionGranted(): boolean {
return this.microphonePermission.isMicrophonePermissionGranted()
}
}
let audioPermissionInstance: AudioPermission | null = null
/**
* 创建或获取音频权限管理实例
*/
function createAudioPermission(): AudioPermission {
if (!audioPermissionInstance) {
audioPermissionInstance = new AudioPermission()
}
return audioPermissionInstance
}
export default createAudioPermission
export { AudioPermission, AudioPermissionError, AudioPermissionErrorType, AudioPlaybackPermission, MicrophonePermission }
js
import createAudioPermission from "./create-audio-permission"
const audioPermissionInstance = createAudioPermission()
const microphoneButton = document.createElement('button')
microphoneButton.innerHTML = '麦克风权限'
microphoneButton.addEventListener('click', () => {
// 获取麦克风权限
audioPermissionInstance.requestMicrophonePermission().then(() => {
console.log('麦克风权限获取成功')
}).catch((error) => {
console.log(error.message)
})
})
document.body.appendChild(microphoneButton)
实战效果
