WebRTC 全景实战 (1):浏览器媒体 API 与设备管理
"在建立任何 P2P 连接之前,你得先拿到媒体。"
上一篇 Ch0 架构全景 我们画了协议栈地图。本章从栈顶最直观的入口开始:如何把摄像头、麦克风和屏幕变成浏览器里的 MediaStream。
Serge Lachapelle 在 WebRTC for the Curious — 历史 中回忆:Gmail 语音视频聊天的前身需要分别授权 GIPS 音频、Vidyo 视频、libjingle 网络三个子系统,"每个子系统都有完全不同的 API"。WebRTC 标准化工作的核心目标之一,就是把 媒体采集 这一层统一成开发者今天使用的 navigator.mediaDevices API——让你不必再为每个浏览器插件写一套集成代码。
配套代码:examples/webrtc-lab/client/ch01-media-devices/
本篇术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| 媒体采集 | Media Capture | 通过浏览器 API 从摄像头、麦克风或屏幕获取原始音视频数据的过程 |
| 媒体流 | MediaStream | 一个或多个 MediaStreamTrack 的容器,是 getUserMedia / getDisplayMedia 的返回值 |
| 媒体轨道 | MediaStreamTrack | 单路音频或视频数据通道,拥有独立的生命周期与 readyState |
| 约束 | Constraints | 向浏览器声明期望分辨率、帧率、设备 ID 等参数的键值对象 |
| 设备枚举 | enumerateDevices | 列出当前系统可用的输入/输出设备,返回 deviceId、kind、label |
| 安全上下文 | Secure Context | HTTPS 或 localhost 环境;非安全上下文下 getUserMedia 会被拒绝 |
| 权限提示 | Permission Prompt | 浏览器弹出的摄像头/麦克风授权对话框,必须由用户手势触发 |
| 轨道替换 | replaceTrack | RTCRtpSender.replaceTrack() 在不停 PeerConnection 的情况下切换发送轨道 |
| 屏幕共享 | Display Capture | getDisplayMedia() 采集屏幕、窗口或浏览器 Tab 的视频(及可选系统音频) |
| 设备变更 | devicechange | navigator.mediaDevices 上的事件,USB 设备插拔时触发,需重新 enumerateDevices |
| 适配层 | adapter.js | WebRTC 官方维护的跨浏览器 shim,抹平 API 前缀与行为差异 |
| 采集设置 | getSettings() | 轨道实际生效的参数(分辨率、帧率、deviceId),可能与 Constraints 不同 |
| 能力查询 | getCapabilities() | 设备/轨道支持的参数范围,用于构建合理的 Constraints |
| 权限策略 | Permissions-Policy | HTTP 响应头,控制 iframe 和页面是否允许调用摄像头/麦克风 |
| 内容提示 | Content Hint | 通过 track.contentHint 告知编码器内容类型(运动/细节/文本),影响码率分配 |
一、Media Capture API 在协议栈中的位置
WebRTC 媒体采集由 W3C Media Capture and Streams 规范定义。它位于整个 WebRTC 栈的最顶端——在 SDP 协商、ICE 穿透、DTLS 握手之前,你必须先拿到 MediaStream。
| API | 规范入口 | 用途 |
|---|---|---|
navigator.mediaDevices.getUserMedia() | Media Capture §5.1 | 摄像头 + 麦克风 |
navigator.mediaDevices.getDisplayMedia() | Screen Capture §3 | 屏幕 / 窗口 / Tab 共享 |
navigator.mediaDevices.enumerateDevices() | Media Capture §4.3 | 枚举设备列表 |
navigator.mediaDevices.getSupportedConstraints() | Media Capture §4.4 | 查询当前浏览器支持的约束键 |
MediaStream | Media Capture §2.1 | 媒体流容器 |
MediaStreamTrack | Media Capture §2.3 | 单路音频或视频轨道 |
1.1 从三个子系统到一个 API
Curious 历史 中 Serge 描述的 Gmail 视频聊天架构,是理解今天 API 设计的关键背景:
今天你只需要 navigator.mediaDevices,但理解这段历史有助于解释为何某些浏览器行为仍有差异——底层仍是对接各 OS 的原生媒体框架(Windows Media Foundation、AVFoundation、V4L2 等)。
1.2 Feature Detection 与 API 可用性
/**
* 检测 Media Capture API 是否可用
* 建议在应用启动时调用一次,提前给用户友好提示
*/
function checkMediaSupport() {
const result = {
secureContext: window.isSecureContext,
mediaDevices: !!navigator.mediaDevices,
getUserMedia: !!navigator.mediaDevices?.getUserMedia,
getDisplayMedia: !!navigator.mediaDevices?.getDisplayMedia,
enumerateDevices: !!navigator.mediaDevices?.enumerateDevices,
supportedConstraints: {},
};
if (navigator.mediaDevices?.getSupportedConstraints) {
const keys = navigator.mediaDevices.getSupportedConstraints();
for (const key of keys) {
result.supportedConstraints[key] = true;
}
}
if (!result.secureContext) {
console.error("非安全上下文:请使用 HTTPS 或 localhost");
}
return result;
}
// 示例输出(Chrome):
// supportedConstraints: { width, height, frameRate, facingMode,
// echoCancellation, noiseSuppression, deviceId, ... }
除 localhost 外,getUserMedia 必须在 安全上下文(Secure Context) 下调用。http://192.168.x.x 等局域网 HTTP 地址同样会被拒绝——开发时可用 npx serve + localhost,生产必须 HTTPS。
二、getUserMedia 完整用法
2.1 最小示例与错误处理
/**
* 安全的 getUserMedia 封装
* - 检查 API 是否存在
* - 区分权限拒绝 vs 设备不存在 vs 约束无法满足
*/
async function acquireUserMedia(constraints) {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("当前浏览器不支持 getUserMedia,请使用 Chrome/Firefox/Safari 最新版");
}
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
return stream;
} catch (err) {
// DOMException 名称是排障的第一线索
switch (err.name) {
case "NotAllowedError":
// 用户点击了「拒绝」,或页面未获用户手势
throw new Error("用户拒绝了摄像头/麦克风权限,或页面未在用户交互后调用");
case "NotFoundError":
// 没有摄像头/麦克风硬件,或 deviceId 无效
throw new Error("未找到匹配的音视频设备");
case "NotReadableError":
// 设备被其他应用独占(如 Zoom 占用了摄像头)
throw new Error("设备被占用或驱动异常,请关闭其他占用摄像头的应用");
case "OverconstrainedError":
// exact 约束无法满足
throw new Error(`约束无法满足: ${err.constraint}`);
case "SecurityError":
throw new Error("非安全上下文,请使用 HTTPS 或 localhost");
case "AbortError":
// 硬件错误或系统中断
throw new Error("采集被系统中止,请重试");
default:
throw err;
}
}
}
// 典型调用:宽松 ideal 约束,生产环境首选
const stream = await acquireUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30, max: 60 },
facingMode: "user", // 移动端前置摄像头
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
// 绑定到 <video> 元素预览
const preview = document.getElementById("preview");
preview.srcObject = stream;
preview.muted = true; // 本地预览必须静音,否则回声
preview.playsInline = true; // iOS Safari 内联播放
await preview.play();
2.2 Constraints 语义深度解析
Constraints 是 getUserMedia 最核心的参数机制。理解四种关键字是避免 OverconstrainedError 的关键:
| 关键字 | 语义 | 不满足时 |
|---|---|---|
exact | 必须精确匹配 | 抛出 OverconstrainedError,调用失败 |
ideal | 尽量满足,可降级 | 浏览器选最接近的值,不失败 |
min | 下限 | 实际值 ≥ min,否则失败 |
max | 上限 | 实际值 ≤ max,否则失败 |
// ❌ 危险:exact 在生产环境极易失败
const risky = await getUserMedia({
video: { width: { exact: 1920 }, height: { exact: 1080 } },
});
// ✅ 推荐:ideal + 事后确认
const safe = await getUserMedia({
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
frameRate: { ideal: 30, max: 60 },
},
});
const videoTrack = safe.getVideoTracks()[0];
const settings = videoTrack.getSettings();
console.log(`实际分辨率: ${settings.width}x${settings.height} @ ${settings.frameRate}fps`);
console.log(`设备 ID: ${settings.deviceId}`);
getCapabilities() vs getSettings():
const track = stream.getVideoTracks()[0];
const caps = track.getCapabilities(); // 硬件支持的范围
const settings = track.getSettings(); // 当前生效的值
// caps.width = { min: 320, max: 3840 } — 摄像头物理能力
// settings.width = 1280 — 本次采集实际值
// 用 caps 构建合理的 ideal 约束
function buildVideoConstraints(track) {
const caps = track.getCapabilities();
const targetWidth = Math.min(1280, caps.width?.max ?? 1280);
const targetHeight = Math.min(720, caps.height?.max ?? 720);
return {
width: { ideal: targetWidth },
height: { ideal: targetHeight },
frameRate: { ideal: 30, max: caps.frameRate?.max ?? 30 },
};
}
生产建议流程:
2.3 音频 Constraints 详解
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
// 回声消除 — 会议场景必须开启
echoCancellation: { ideal: true },
// 噪声抑制
noiseSuppression: { ideal: true },
// 自动增益 — 音量过小时自动放大
autoGainControl: { ideal: true },
// 指定麦克风(需先 enumerateDevices 拿到 deviceId)
deviceId: { ideal: selectedMicId },
// 采样率(部分浏览器支持)
sampleRate: { ideal: 48000 },
// 声道数
channelCount: { ideal: 1 },
// 延迟模式 — "interactive" 低延迟,"speech-recognition" 优化识别
latency: { ideal: 0.01 },
},
video: false, // 仅采集音频
});
| 音频约束 | 推荐值 | 说明 |
|---|---|---|
echoCancellation | true | 消除扬声器回声,1v1 和会议必备 |
noiseSuppression | true | 抑制环境噪声 |
autoGainControl | true | 自动调节输入音量 |
sampleRate | 48000 | WebRTC 默认 48kHz,不建议改 |
channelCount | 1 | 单声道足够,节省带宽 |
2.4 视频高级约束
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
// 对焦模式 — 部分摄像头支持
focusMode: { ideal: "continuous" },
// 曝光模式
exposureMode: { ideal: "continuous" },
// 白平衡
whiteBalanceMode: { ideal: "continuous" },
// 移动端前后摄像头
facingMode: "user", // "user" | "environment"
// 分辨率宽高比
aspectRatio: { ideal: 16 / 9 },
},
});
focusMode、exposureMode 等高级约束并非所有摄像头都支持。先用 getSupportedConstraints() 检查浏览器支持,再用 getCapabilities() 检查设备支持。
三、enumerateDevices 与设备管理
3.1 枚举流程与 label 隐私
/**
* 枚举所有媒体设备
* 注意:在用户授权 getUserMedia 之前,label 可能是空字符串(隐私保护)
*/
async function listMediaDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
const grouped = {
videoinput: [], // 摄像头
audioinput: [], // 麦克风
audiooutput: [], // 扬声器(仅部分浏览器支持 setSinkId)
};
for (const device of devices) {
grouped[device.kind]?.push({
deviceId: device.deviceId,
label: device.label || `${device.kind} (${device.deviceId.slice(0, 8)}…)`,
groupId: device.groupId, // 同一物理设备的不同 kind 共享 groupId
});
}
return grouped;
}
同一物理设备(如带麦克风的 USB 摄像头)的 videoinput 和 audioinput 条目会共享相同的 groupId。切换摄像头时可同时切换对应麦克风,避免音视频来自不同设备。
3.2 扬声器选择:setSinkId
部分浏览器支持将音频输出路由到指定扬声器:
/**
* 切换音频输出设备(Chrome / Edge 支持,Safari 不支持)
* @param {HTMLMediaElement} element - <video> 或 <audio>
* @param {string} deviceId - audiooutput 的 deviceId
*/
async function setAudioOutput(element, deviceId) {
if (typeof element.setSinkId !== "function") {
console.warn("当前浏览器不支持 setSinkId");
return;
}
await element.setSinkId(deviceId);
}
// 用法
const devices = await navigator.mediaDevices.enumerateDevices();
const speakers = devices.filter((d) => d.kind === "audiooutput");
await setAudioOutput(remoteVideo, speakers[0].deviceId);
3.3 设备热切换与 replaceTrack
切换摄像头是会议产品的常见需求。正确做法是 替换 Track,而非重建整个 RTCPeerConnection:
/**
* 切换到指定摄像头
* @param {RTCPeerConnection} pc - 已建立的 PeerConnection(Ch2 会用到)
* @param {string} deviceId - 目标摄像头 deviceId
* @param {MediaStream} currentStream - 当前本地流
*/
async function switchCamera(pc, deviceId, currentStream) {
// 1. 用新 deviceId 采集
const newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId }, width: { ideal: 1280 } },
audio: false, // 只换视频轨,音频保持不变
});
const newVideoTrack = newStream.getVideoTracks()[0];
const oldVideoTrack = currentStream.getVideoTracks()[0];
// 2. 如果已有 PeerConnection,替换 sender 上的 track
if (pc) {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
if (sender) {
await sender.replaceTrack(newVideoTrack);
// replaceTrack 不会触发 renegotiation(同一编码器类型时)
}
}
// 3. 更新本地预览
currentStream.removeTrack(oldVideoTrack);
oldVideoTrack.stop(); // 释放硬件资源!
currentStream.addTrack(newVideoTrack);
return newVideoTrack;
}
replaceTrack() 是生产会议中「切换摄像头」的标准做法,Ch12 SFU 场景同样适用。注意:必须 stop() 旧 track,否则摄像头指示灯不会熄灭,硬件资源被泄漏。
| 切换场景 | 正确做法 | 错误做法 |
|---|---|---|
| 换摄像头 | getUserMedia + replaceTrack + stop() 旧轨 | 重建 PeerConnection |
| 关摄像头 | track.enabled = false 或 stop() | 只隐藏 <video> 元素 |
| 静音 | track.enabled = false | stop() 音频轨(需重新采集) |
四、MediaStream 与 MediaStreamTrack 生命周期
4.1 对象关系
4.2 Track 状态机
| 属性/状态 | 含义 | 常见误区 |
|---|---|---|
readyState: "live" | 正常采集 | — |
readyState: "ended" | 已停止,不可恢复 | 误以为可以 restart |
enabled: false | 轨道暂停发送黑帧/静音,但硬件仍占用 | 与 stop() 不同 |
muted: true | 轨道仍在,但不产出数据 | 用户关摄像头盖时触发 |
4.3 enabled vs stop vs muted
const videoTrack = stream.getVideoTracks()[0];
// 方式 1:enabled = false — 暂停发送,硬件仍占用,可快速恢复
videoTrack.enabled = false; // 发送黑帧
videoTrack.enabled = true; // 立即恢复
// 方式 2:stop() — 彻底释放硬件,不可恢复
videoTrack.stop(); // readyState → "ended",需重新 getUserMedia
// 方式 3:muted — 系统/硬件触发,应用无法直接控制
videoTrack.onmute = () => console.log("硬件静音(如合上笔记本盖)");
4.4 事件监听与资源清理
function attachTrackListeners(track, label) {
track.onended = () => {
console.log(`[${label}] track ended — 用户或系统停止了采集`);
// 更新 UI:显示「摄像头已关闭」
};
track.onmute = () => {
console.log(`[${label}] track muted — 暂时无数据`);
};
track.onunmute = () => {
console.log(`[${label}] track unmuted — 恢复数据`);
};
}
/**
* 完整清理 — 页面卸载 / 用户挂断时必须调用
* 遗漏 stop() 会导致摄像头指示灯常亮
*/
function releaseMediaStream(stream) {
if (!stream) return;
stream.getTracks().forEach((track) => {
track.stop();
stream.removeTrack(track);
});
}
// 页面关闭时自动清理
window.addEventListener("beforeunload", () => {
releaseMediaStream(currentStream);
});
// SPA 路由切换时也要清理
// React: useEffect(() => () => releaseMediaStream(stream), []);
4.5 applyConstraints 运行时调整
无需重新 getUserMedia,可在 live 状态下动态调整部分参数:
const videoTrack = stream.getVideoTracks()[0];
// 运行时降低分辨率(节省带宽,Ch10 会深入)
await videoTrack.applyConstraints({
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 15 },
});
// 确认生效
console.log(videoTrack.getSettings());
4.6 clone() 与 contentHint
// clone() — 创建独立副本,两个 track 共享同一硬件源
const clonedTrack = videoTrack.clone();
// 用途:一个 track 给本地预览,一个给 PeerConnection
// contentHint — 告知编码器内容类型,影响码率分配策略
videoTrack.contentHint = "motion"; // 运动场景(体育、游戏)
// videoTrack.contentHint = "detail"; // 细节场景(文档、白板)
// videoTrack.contentHint = "text"; // 文字场景(代码、字幕)
并非所有约束都支持运行时修改。deviceId 变更必须重新 getUserMedia + replaceTrack。
五、屏幕共享 getDisplayMedia
5.1 基本用法
async function startScreenShare() {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: "always", // 显示鼠标光标
displaySurface: "monitor", // 理想:整个屏幕(浏览器可能忽略)
},
audio: true, // Chrome 支持采集 Tab 音频 / 系统音频
// 注意:getDisplayMedia 不接受 deviceId 约束
// 注意:必须由用户手势触发
});
const screenTrack = screenStream.getVideoTracks()[0];
const settings = screenTrack.getSettings();
console.log(`共享类型: ${settings.displaySurface}`);
// "monitor" | "window" | "browser"
// 用户从系统 UI 停止共享时触发
screenTrack.onended = () => {
console.log("用户点击了「停止共享」");
// 恢复摄像头画面或更新 UI
};
return screenStream;
} catch (err) {
if (err.name === "NotAllowedError") {
console.log("用户取消了屏幕选择");
}
throw err;
}
}
5.2 getUserMedia vs getDisplayMedia
| 对比项 | getUserMedia | getDisplayMedia |
|---|---|---|
| 用户交互 | 权限弹窗(首次) | 每次调用都弹出选择器 |
| 停止方式 | 应用调用 track.stop() | 用户可从 OS 栏停止 → onended |
| 音频 | 麦克风 | Tab 音频 / 系统音频(浏览器依赖) |
| Constraints | 完整支持 | 仅 video / audio 布尔或少量字段 |
| 移动端 | 广泛支持 | iOS Safari 16+ 有限支持 |
| 系统音频 | 不适用 | Chrome Tab 共享时可采集 Tab 音频 |
5.3 屏幕共享 + 摄像头画中画
/**
* 同时发送摄像头和屏幕 — 需要两个 VideoTrack
* Ch2 会学到用 addTransceiver 或两次 addTrack 发送多路视频
*/
async function startCameraAndScreen(pc) {
const cameraStream = await navigator.mediaDevices.getUserMedia({
video: true, audio: true,
});
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true, audio: false,
});
// 摄像头作为主轨道
for (const track of cameraStream.getTracks()) {
pc.addTrack(track, cameraStream);
}
// 屏幕作为第二条视频轨(需要 renegotiation)
const screenTrack = screenStream.getVideoTracks()[0];
pc.addTrack(screenTrack, screenStream);
screenTrack.onended = () => {
const sender = pc.getSenders().find((s) => s.track === screenTrack);
if (sender) pc.removeTrack(sender);
// 触发 negotiationneeded → 需要重新 Offer/Answer(Ch2)
};
}
六、权限模型与用户手势
6.1 Permissions API 查询
/**
* 查询当前权限状态(Chrome / Firefox 支持)
* 返回值: "granted" | "denied" | "prompt"
*/
async function checkPermissions() {
const results = {};
for (const name of ["camera", "microphone"]) {
try {
const status = await navigator.permissions.query({ name });
results[name] = status.state;
// 监听权限变化(用户可能在地址栏修改)
status.onchange = () => {
console.log(`${name} 权限变为: ${status.state}`);
if (status.state === "denied") {
releaseMediaStream(currentStream);
}
};
} catch {
results[name] = "unsupported";
}
}
return results;
}
6.2 Permissions-Policy HTTP 头
即使页面有 HTTPS,服务器也可以通过 HTTP 头禁止媒体采集:
Permissions-Policy: camera=(self), microphone=(self), display-capture=(self)
iframe 嵌入时需要显式授权:
<iframe
src="https://your-app.com/call"
allow="camera; microphone; display-capture"
></iframe>
6.3 用户手势要求
最佳实践:
- 永远在用户点击「开始通话」「开启摄像头」等按钮后调用
getUserMedia - 权限被拒绝后,引导用户到浏览器设置页手动开启,不要反复弹窗
- 使用
Permissions API在 UI 上提前展示状态(摄像头图标灰色/绿色) - 持久化权限:浏览器会记住
granted状态,同一 origin 下次不再弹窗
七、devicechange 事件
USB 摄像头插拔、蓝牙耳机连接断开时,设备列表会变化:
// 注册监听器 — 整个页面生命周期只需一次
navigator.mediaDevices.addEventListener("devicechange", async () => {
console.log("媒体设备列表发生变化");
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter((d) => d.kind === "videoinput");
// 检查当前使用的 deviceId 是否还存在
const currentTrack = currentStream?.getVideoTracks()[0];
const currentDeviceId = currentTrack?.getSettings().deviceId;
const stillExists = cameras.some((d) => d.deviceId === currentDeviceId);
if (!stillExists && cameras.length > 0) {
// 当前设备被拔出,自动切换到第一个可用摄像头
await switchCamera(pc, cameras[0].deviceId, currentStream);
}
// 刷新 UI 下拉列表
await refreshDeviceSelectors(devices);
});
devicechange 只通知设备列表变化,不会自动停止或切换正在使用的 track。被拔出的设备对应的 track 会触发 onended,你需要同时监听两个事件。
八、跨浏览器兼容:adapter.js
不同浏览器对 WebRTC 的实现存在细微差异。在 WebRTC 标准化之前(参见 Curious 历史 中 Serge 描述的「每个子系统不同 API」困境),开发者需要处理 webkitGetUserMedia、mozGetUserMedia 等前缀。
adapter.js 是官方维护的 shim:
<!-- CDN 引入 -->
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
// npm 引入(现代项目)
import "webrtc-adapter";
adapter.js 统一了:
| 差异点 | 无 adapter | 有 adapter |
|---|---|---|
getUserMedia 前缀 | webkit / moz | 统一 navigator.mediaDevices.getUserMedia |
RTCPeerConnection 前缀 | webkitRTCPeerConnection | 统一构造函数 |
attachMediaStream | 旧式 API | 自动 shim |
| Safari 特定行为 | 需手动处理 | 内置 workaround |
Promise 化 | 旧 API 用 callback | 统一返回 Promise |
8.1 浏览器差异速查
| 特性 | Chrome | Firefox | Safari | 备注 |
|---|---|---|---|---|
| getUserMedia | ✅ | ✅ | ✅ | 均需 HTTPS |
| getDisplayMedia | ✅ | ✅ | ✅ 16+ | Safari 有限制 |
| enumerateDevices label | ✅ | ✅ | ✅ | 需先授权 |
| setSinkId | ✅ | ❌ | ❌ | 仅 Chromium |
| devicechange | ✅ | ✅ | ✅ | — |
| facingMode | ✅ | ✅ | ✅ | 移动端 |
| applyConstraints | ✅ | ✅ | 部分 | 因设备而异 |
examples/webrtc-lab 面向现代 Chrome/Firefox/Safari,暂不强制依赖 adapter.js。生产项目建议引入,尤其是需支持旧版浏览器或 Electron 内嵌 WebView 时。
九、常见问题与踩坑指南
Q1: 为什么 enumerateDevices 返回的 label 是空的?
A: 浏览器隐私策略——在用户未授权 getUserMedia 之前,不暴露设备名称。先调用一次 getUserMedia(或用户授权后),再 enumerateDevices 即可获取 label。
Q2: 切换摄像头后远端画面没有变化?
A: 检查是否调用了 sender.replaceTrack(newTrack) 而非仅更新本地预览。本地 <video> 的 srcObject 和 PeerConnection 的 sender 是独立路径。
Q3: 页面关闭后摄像头指示灯还亮着?
A: 遗漏了 track.stop()。确保在 beforeunload、pagehide 和组件 unmount 时都调用 releaseMediaStream()。
Q4: getUserMedia 在 iframe 中不工作?
A: iframe 需要 allow="camera; microphone" 属性,且 iframe 自身也必须在安全上下文中。父页面还需设置 Permissions-Policy 允许嵌入。
Q5: 移动端前后摄像头怎么切换?
A: 使用 facingMode 约束而非 deviceId(移动端 deviceId 可能不稳定):
// 前置
{ video: { facingMode: "user" } }
// 后置
{ video: { facingMode: "environment" } }
Q6: 为什么 Constraints 设置了 1080p 实际只有 360p?
A: 浏览器在 CPU/带宽压力下会自动降级。用 track.getSettings() 确认实际值;如需强制,用 min 约束但要做好失败兜底。
Q7: getDisplayMedia 和 getUserMedia 能否同时调用?
A: 可以。两个独立的 MediaStream,分别有不同 Track。注意最终传给 PeerConnection 时需要管理多条 Track(Ch2 / Ch12 详述)。
Q8: 本地预览有回声怎么办?
A: 本地 <video> 必须设置 muted = true。否则麦克风采集到扬声器播放的声音,形成回声。远端 <video> 不要静音。
Q9: 没有摄像头怎么测试?
A: Chrome 启动参数 --use-fake-device-for-media-stream 会注入假设备;或在 chrome://flags 启用虚拟摄像头。CI 环境常用 node-webrtc 或 Puppeteer 的 fake media 模式。
十、实战 Lab
10.1 运行 Ch01 Demo
cd examples/webrtc-lab/client/ch01-media-devices
npx serve .
# 打开 http://localhost:3000
10.2 练习清单
| # | 练习 | 预期观察 |
|---|---|---|
| 1 | 点击「开始预览」,允许权限 | 本地 <video> 出现画面;enumerateDevices 返回完整 label |
| 2 | 在下拉框切换摄像头 | 画面切换;chrome://webrtc-internals 中可见新 deviceId |
| 3 | 插入/拔出 USB 摄像头 | 控制台输出 devicechange;下拉列表自动刷新 |
| 4 | 点击「屏幕共享」 | 系统选择器弹出;选中后预览变为屏幕;点击 OS 停止栏触发 onended |
| 5 | 点击「停止」 | 摄像头指示灯熄灭;track.readyState 变为 ended |
| 6 | 打开 chrome://webrtc-internals | 查看 getUserMedia 请求参数、实际分辨率、帧率 |
10.3 扩展挑战
- 权限状态 UI:用
navigator.permissions.query在按钮旁显示摄像头/麦克风权限图标 - 能力自适应:读取
getCapabilities(),根据max值动态生成分辨率选项 - 轨道信息面板:实时展示每个 track 的
kind、readyState、muted、getSettings() - 错误恢复:
NotReadableError时自动重试 3 次,每次间隔 1 秒 - contentHint 实验:分别设置
"motion"和"detail",在chrome://webrtc-internals观察码率差异
10.4 Demo 核心代码导读
Lab 的 main.js 实现了本章大部分概念:
// examples/webrtc-lab/client/ch01-media-devices/main.js(节选)
async function startPreview(constraints = {}) {
stopTracks(); // 先释放旧轨道,避免泄漏
currentStream = await navigator.mediaDevices.getUserMedia({
video: videoDeviceId
? { deviceId: { exact: videoDeviceId }, width: { ideal: 1280 }, height: { ideal: 720 } }
: true,
audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true,
...constraints,
});
preview.srcObject = currentStream;
await enumerateDevices(); // 授权后刷新 label
}
navigator.mediaDevices.addEventListener("devicechange", () => {
enumerateDevices(); // 设备插拔时刷新列表
});
十一、本章小结
| 要点 | 内容 |
|---|---|
| 入口 API | getUserMedia / getDisplayMedia / enumerateDevices |
| 核心对象 | MediaStream → MediaStreamTrack,理解生命周期与事件 |
| Constraints | 生产用 ideal,事后 getSettings() 确认;避免 exact |
| 设备切换 | enumerateDevices + getUserMedia(新 deviceId) + replaceTrack |
| 权限 | HTTPS + 用户手势 + Permissions API + Permissions-Policy |
| 设备热插拔 | 监听 devicechange + track onended,检查当前 track 有效性 |
| 跨浏览器 | adapter.js 抹平前缀差异 |
| 资源清理 | track.stop() 释放硬件,页面卸载必须清理 |
下一篇(Ch2) 我们把采集到的 MediaStream 通过 RTCPeerConnection 发送给另一个浏览器,用 Offer/Answer 模型完成第一个 P2P 视频通话。
系列导航
章节 主题 状态 0 架构全景与协议栈地图 ✅ 已发布 1 浏览器媒体 API 与设备管理 ✅ 已发布 2 第一个 P2P 视频通话 ✅ 已发布 3 信令服务器设计与会话状态机 ✅ 已发布 4 SDP 解剖与媒体协商 ✅ 已发布 5 ICE、STUN、TURN 与 NAT 穿透 ✅ 已发布 6 Data Channel 与 SCTP over DTLS ✅ 已发布 7 DTLS 握手与 SRTP 加密体系 ✅ 已发布 8 RTP/RTCP 媒体传输与 QoS ✅ 已发布 9 音视频编解码与 Simulcast 入门 ✅ 已发布 10 带宽估计与拥塞控制 GCC ✅ 已发布 11 Simulcast、SVC 与选择性订阅 ✅ 已发布 12 SFU/MCU/Mesh 架构与 Pion 实战 ✅ 已发布 13 调试工具链与可观测性 ✅ 已发布 14 TURN 集群部署与多区域扩展 ✅ 已发布 15 Capstone 生产级视频会议系统 ✅ 已发布
References
- W3C Media Capture and Streams
- W3C Screen Capture
- MDN — MediaDevices.getUserMedia()
- MDN — MediaDevices.getDisplayMedia()
- MDN — MediaStreamTrack
- MDN — Permissions API
- MDN — Permissions-Policy
- WebRTC Samples — getUserMedia
- WebRTC Samples — getDisplayMedia
- adapter.js
- WebRTC for the Curious — 历史
- Ch0 架构全景
- Ch2 第一个 P2P 视频通话