要实现良好的 WebRTC 对话,首先必须为用户提供合适的设备。
想象一下,你正在与朋友进行视频通话,却因为麦克风不合适而听不到他的声音。或者你看不到他,因为他共享了一个虚拟摄像头……这就不好玩了,不是吗?
为了避免这些情况,在 WebRTC 应用程序中正确管理设备非常重要。但这并不总是那么容易,尤其是当你要临时连接和断开设备,或配对 AirPods 等新设备时。
为什么不容易?因为每个浏览器实现设备管理的方式都不一样,导致不同浏览器之间的设备管理不一致,困难重重。
更糟糕的是,这个 API 是在 2013 年设计的,十多年过去了,我们仍然在使用它时遇到问题。
在本文中,我将探讨如何在 WebRTC 应用程序中管理设备。
作者:Olivier Anguenot
编译:小及狗
原文:https://www.webrtc-developers.com/managing-devices-in-webrtc/
简介
在 WebRTC 应用程序中管理设备涉及几个关键步骤:
1、授权:设备管理的第一步是请求并获得用户访问其媒体设备(摄像头、麦克风)的权限。这通常使用 navigator.mediaDevices.getUserMedia()
方法完成,该方法会询问用户是否允许访问其摄像头和/或麦克风。
2、枚举: 获得访问权限后,下一步就是枚举可用的媒体设备。这可以使用 navigator.mediaDevices.enumerateDevices()
方法来完成。您将得到可用设备的列表,包括其 ID、标签和类型(即audioinput
、audiooutput
、videoinput
)。
3、选择:列出设备后,用户应能为音频和视频输入选择所需的设备。同样,可以使用 navigator.mediaDevices.getUserMedia() 方法来完成这一操作,但这次要使用指定所需设备 ID 的约束条件。
4、检测:最后一步是检测可用设备的变化,例如连接了新设备或断开了现有设备的连接。这可以通过监听 navigator.mediaDevices 对象上的事件来实现。
权限
没有通用规则?
此步骤是强制性的,应用程序要求用户允许访问网站或应用程序 (SPA) 设备的权限。
为什么需要这样做?因为你不希望应用程序在未经同意的情况下访问你的摄像头和麦克风。这是一个隐私问题。对于摄像头,很容易就能知道它是否被使用,但对于麦克风,就不那么容易了。如果你不注意浏览器工具栏或系统工具栏,如何知道应用程序是否在监听你?
请求权限的方法是调用 navigator.mediaDevices.getUserMedia()
方法,并说明想要获取的媒体类型:音频、视频或两者。需要说明的是,如果不请求使用特定设备,浏览器会为您选择(Chrome/Safari)或允许您选择使用哪种设备(Firefox)。
因此,每个浏览器处理权限的方式并不完全相同。它们的共同点是,授权与域有关。
在 Chrome 中,默认情况下,你需要为所有想要的设备申请授权。而不是针对某个特定设备。因此,一旦你有了使用摄像头的权限,就不需要其他权限来使用其他摄像头了。此外,还可以为单个会话或永久授权。你还可以阻止权限。
在 Safari 中,这也是按域进行的。但每次重新加载页面时,都必须再次请求权限。
在 Firefox 中,总是为特定设备授予权限。如果你想使用其他设备,就必须再次申请权限。不过,Firefox 最近增加了授权所有同类型设备(如所有摄像头或所有麦克风)的选项。
根据用户的选择,当用户想要切换到不同的设备时,体验可能会有所不同。
系统权限
请注意,MacOS 等操作系统需要额外的权限(首次使用浏览器时),以全局授予浏览器访问设备的权限。
请注意,该权限只适用于 Safari 以外的浏览器…
一旦授权被接受,系统就不会再向你询问。
但如果你不小心拒绝了这项权限,会发生什么情况呢?
调用 getUserMedia 时,即使用户授权,应用程序也无法访问设备。收到的错误信息会有所不同:
- 在 Firefox 中,它将生成一个
DOMException: The object can not be found here
。 - 在 Chrome 中,将会出现错误
NotAllowedError: Permission denied by system
。
在Chrome中,你可以推断出权限被系统拒绝了。在Firefox中,情况就不那么明显了。
Permissions API
通过Permissions API,你可以随时查询该权限。
请注意,Firefox 仍未管理麦克风和摄像头的权限,因此该 API 并不适用于所有浏览器。
下面是最新 Chrome Canary 129 中的一个示例:
try {
const permission = await navigator.permissions.query({ name: 'camera' });
console.log(permission.state);
// 授予、拒绝、提示
} catch(err) {
// 处理错误
}
注:目前(3 月 24 日最后一次更新),只有以下权限已标准化:geolocation
, notifications
, push
和 web-share
。其他权限仍在草案中。
删除或重置权限
任何时候都可以删除权限。不是由应用程序,而是由用户。
- 进入系统设置(在 MacOS 上),可以全面删除 Safari 以外的浏览器访问设备的权限。
- 在浏览器设置中,可以删除或重置特定域的任何权限。
如前所述,如果用户错误地拒绝了授权,应用程序可以检测到并建议用户再次申请权限。
- 在 Chrome 中,应用程序收到错误
DOMException: Permission denied
- 在 Firefox 中,应用程序收到错误
DOMException: The request is not allowed by the user agent or the platform in the current context
。 - 在 Safari 中:错误
NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
检测此错误可以向用户提供重置权限的方法。
继续
getUserMedia
API 是一个 “一体化 “API,也就是说,如果成功,你将获得一个流(RTCMediaStream 类型),其中包含要求的音轨(RTCMediaStreamTrack 类型)(即音频、视频或两者)。
因此,如果你已经要求了特定的限制条件,就不需要再做额外的事情。可以直接使用流。
下面是一个简单的示例,说明如何请求访问摄像头和麦克风的权限:
const constraints = {
audio: true,
video: true,
};
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch(err) {
// 处理错误
}
设备枚举
一旦获得权限,就可以列出系统中可用的设备。方法是 navigator.mediaDevices.enumerateDevices()。
该方法会返回一个Promise,该 Promise 会解析一个 MediaDeviceInfo 对象数组。每个对象代表一个媒体输入或输出设备,如麦克风、摄像头或扬声器。MediaDeviceInfo 对象包含有关设备的信息,包括设备 ID、组 ID、类型(即audioinput
、audiooutput
、videoinput
)和标签。
以下是在我电脑上的不同浏览器中请求相同的基本约束(如音频和视频)后的枚举结果:
浏览器 | 全部 | Audio Input | Audio Output | Video Input |
---|---|---|---|---|
Chrome | 24 | 默认 +3个物理设备 +7个虚拟设备 | 默认 + 5个物理设备 5个虚拟设备 | 1 个物理设备 1 个虚拟设备 |
Firefox | 19 | +3个物理设备 +7个虚拟设备 | + 2 个物理设备 5 个虚拟设备 | 1 个物理设备 1 个虚拟设备 |
Safari | 12 | +3个物理设备 +7个虚拟设备 | – | 1 个物理设备 1 个虚拟设备 |
主要区别在于:
- Chrome 会将默认设备(输入和输出)添加到设备列表中。事实上,默认设备是已存在的设备,其 id 已被默认设置取代。如果不要求使用特定设备,Chrome 将使用默认设备,而 Firefox/Safari 浏览器则使用列表中的第一个设备。
- Safari 仍然不支持输出设备
- Firefox 不显示内置扬声器: 集成到我的 Mac Mini 的扬声器和 2 个显示器(HDMI 和 DisplayPort)的扬声器。
将设备进行分组
有些设备可以同时管理音频和视频或音频输入和输出。例如,带内置麦克风的网络摄像头或带内置扬声器的麦克风,如会议室中使用的设备。
在这种情况下,将这些设备分组以允许用户将它们选择为单个设备是有意义的,这意味着选择一个设备会自动选择同一组中的另一个设备。
由于 MediaDeviceInfo 对象的 groupId
属性,这种关联成为可能。该属性是设备所属设备组的唯一标识符。
如果两台设备属于同一物理设备,则它们具有相同的组标识符;例如,具有内置摄像头和麦克风MDN的显示器。
与往常一样,浏览器中的行为有所不同:
- Firefox 将设备以及所有虚拟设备分组
- Chrome 只对设备进行分组,而不是对所有虚拟设备进行分组(例如,对于 Teams 音频设备可以,但对于 Rode Connect 虚拟设备则不行)
- Safari 并不关心分组设备…(还没有?)
输入设备选择
列出设备后,就可以让用户为音频和视频输入选择所需的设备。这可以通过在传递给 getUserMedia()
方法的约束对象中指定设备 ID 来实现。
以下是选择麦克风的示例:
const constraints = {
audio: {
deviceId: {
exact: "0e05387a88dec20949ff8d8d18ee288ed4d7271a8d4380b497feb1432300c4bd",
},
},
};
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch(err) {
// 处理错误
}
注意:如果用户拒绝许可或设备不可用,请记住捕捉错误。
输出设备选择
在 Firefox 和 Chrome 浏览器中,可以使用 HTMLMediaElement
对象上的 setSinkId()
方法选择扬声器。该方法需要音频输出媒体设备的 deviceId
。
下面是一个如何使用 setSinkId()
方法的示例:
const audio = document.querySelector('#audio');
audio.setSinkId("51D9CC25B5DFDD54160FC1E357577D50116FDA89");
Firefox 则更进一步,实现了 navigator.mediaDevices.selectAudioOutput
API。该 API 允许用户从显示所有设备的弹出窗口中选择输出设备。
我在 Firefox Nightly (130) 上进行了测试,该 API 还提供了内置扬声器。
请注意,该 API 还未完全标准化,需要用户做出指令(例如点击按钮)才能工作。这是为了防止应用程序在未经同意的情况下将媒体发送到外部扬声器。
此规范可在此处获取:Audio Output Devices API
已更改的设备
有趣的部分
来自规范:
The set of media devices, available to the User Agent, has changed. The current list of devices is available in the devices attribute。—— 用户代理可用的媒体设备集已更改。设备当前列表可在设备属性中找到
用户代理可使用的媒体设备集已发生变化。当前的设备列表可在设备属性中找到。
目前,浏览器并没有按照规范中的定义实现这一事件。
以下是我观察到的情况:
浏览器 | 事件触发时间 | 设备属性 |
---|---|---|
Chrome | 默认设备在系统级别(OS)发生更改 | NO |
新设备已配对(1x 或 2x) | NO | |
设备被移除(1x 或 2x) | NO | |
Firefox | 已配对新设备 (1x) | NO |
设备被移除 (1x) | NO | |
Safari | 默认设备在系统级别(OS)发生更改 | NO |
已配对新设备 (1x) | NO | |
设备被移除 (1x) | NO |
需要记住的主要事项有:
- 当默认设备在系统级别发生变化时,Firefox 似乎不会触发事件。
- Firefox 仅在浏览器处于活动状态时触发事件。否则,浏览器获得焦点后就会触发事件。
- Chrome 会针对添加或移除的每种设备类型触发事件(即
audioinput
、audiooutput
、videoinput
)。例如,如果配对了 AirPods,你将看到两个事件:一个用于输入,一个用于输出。 - 没有任何浏览器会触发具有规范中定义的
devices
属性事件。
Firefox 案例
由于 Firefox 在系统级更改默认设备时不会触发事件(至少在 MacOS 上是如此),因此应用程序主要在使用扬声器方面会出现不同步。这是我在 Firefox 中看到的主要问题。
我决定防止这一问题的方法是定期调用 enumerateDevices
,并与当前设备列表进行比较。
通过这种方法,我可以在插入或拔出设备时进行检测,并显示一个横幅让用户确认切换到新设备。
Chrome 双重事件
这里的问题有所不同。因此,我选择在收到devicechange
事件后等待几毫秒(即最多 500 毫秒),以避免捕获第二个事件。由于没有 devices
属性,第二个事件除了让你知道发生了两次变化外,没有任何帮助。
延迟之后,我会调用 enumerateDevices
获取设备列表,并相应地更新用户界面。
navigator.mediaDevices.addEventListener('devicechange', async () => {
if (!hasChanged) {
// 避免在第二个事件上做一些事情
hasChanged = true;
setTimeout(async () => {
hasChanged = false;
// 触发这两个事件后执行某些操作
}, 500);
}
});
管理 AirPods
当你打开手机盒取出 AirPods 时,它们会自动与你的 Mac 配对。所有浏览器都会检测到新设备,并将其添加到设备列表中。
有趣的是,只要 AirPods 没有戴在耳朵上,设备就只会添加到设备列表中。
如果你把 AirPods 戴在耳朵上,Chrome 浏览器和 Safari 浏览器就会触发一个新的设备更改事件。为什么?因为 AirPods 现在被用作输入和输出的默认设备。
如果再次调用 enumerateDevices
,你会发现在 Chrome 浏览器中,默认设备发生了变化,而在 Safari 中,AirPods 现在是第一个音频输入设备。
因此,不要想当然地认为devicechange
意味着至少添加或删除了一个设备。不,它也可能意味着更改了默认设备。就像在 macOS 设置中手动更改一样。
替代方案
假设你有一个 MediaStream 流,你可以从它的轨道中获取所使用的设备。
const devices = await navigator.mediaDevices.enumerateDevices();
// Get the tracks
const tracks = stream.getTracks();
// Get the deviceId associated to each track from the settings
const devicesId = tracks.map(track => track.getSettings().deviceId);
// Compare this id to the list of devices your application knows to find the right ones
const devicesUsed = devices.filter(device => devicesId.includes(device.deviceId));
console.log(devicesUsed);
// [ { deviceId: "51D9CC25B5DFDD54160FC1E357577D50116FDA89", kind: "audioinput", label: "Rode NT-Usb", groupId:... }, { deviceId: "E2BF4D17BF7BD448FE0CF9C1141924A9B4FC5237", kind: "videoinput", label: "StreamCam", groupId: ... } ]
在某些情况下,这可以作为确认所用设备的一种方法。
总结
根据本文所述,需要考虑的要点如下:
检测任何被拒绝的权限,尽快帮助用户解决这一问题: 使用 query API 或 getUserMedia API。
显示当前或默认设备,即用户下一次通话将使用的设备: 存储之前使用过的设备并与当前设备列表进行比较,检测设备是否仍然可用。
当用户插入或配对新设备时选择新设备,因为他很可能想使用该设备:依赖 devicechange 事件和 enumerateDevices API。
酌情对设备进行分组: 使用 groupId 属性,如果插入或配对了新设备,则全局切换到该设备(音频和视频或输入和输出)。
在任何情况下,反馈都是帮助用户了解情况的关键。
我们不希望用户必须重新加载页面才能获得新的可用设备……
原创文章,作者:ZEGO即构科技,如若转载,请注明出处:https://market-blogs.zego.im/reports-technique/1623/