Skip to main content

WebRTC 全景实战 (1):浏览器媒体 API 与设备管理

Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
Rainy
24 MIN READ... VIEWS

"在建立任何 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列出当前系统可用的输入/输出设备,返回 deviceIdkindlabel
安全上下文Secure ContextHTTPS 或 localhost 环境;非安全上下文下 getUserMedia 会被拒绝
权限提示Permission Prompt浏览器弹出的摄像头/麦克风授权对话框,必须由用户手势触发
轨道替换replaceTrackRTCRtpSender.replaceTrack() 在不停 PeerConnection 的情况下切换发送轨道
屏幕共享Display CapturegetDisplayMedia() 采集屏幕、窗口或浏览器 Tab 的视频(及可选系统音频)
设备变更devicechangenavigator.mediaDevices 上的事件,USB 设备插拔时触发,需重新 enumerateDevices
适配层adapter.jsWebRTC 官方维护的跨浏览器 shim,抹平 API 前缀与行为差异
采集设置getSettings()轨道实际生效的参数(分辨率、帧率、deviceId),可能与 Constraints 不同
能力查询getCapabilities()设备/轨道支持的参数范围,用于构建合理的 Constraints
权限策略Permissions-PolicyHTTP 响应头,控制 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查询当前浏览器支持的约束键
MediaStreamMedia Capture §2.1媒体流容器
MediaStreamTrackMedia 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, ... }
HTTPS 要求

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, // 仅采集音频
});
音频约束推荐值说明
echoCancellationtrue消除扬声器回声,1v1 和会议必备
noiseSuppressiontrue抑制环境噪声
autoGainControltrue自动调节输入音量
sampleRate48000WebRTC 默认 48kHz,不建议改
channelCount1单声道足够,节省带宽

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 },
},
});
约束支持因设备而异

focusModeexposureMode 等高级约束并非所有摄像头都支持。先用 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;
}
groupId 的用途

同一物理设备(如带麦克风的 USB 摄像头)的 videoinputaudioinput 条目会共享相同的 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 = falsestop()只隐藏 <video> 元素
静音track.enabled = falsestop() 音频轨(需重新采集)

四、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"; // 文字场景(代码、字幕)
applyConstraints 的局限

并非所有约束都支持运行时修改。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

对比项getUserMediagetDisplayMedia
用户交互权限弹窗(首次)每次调用都弹出选择器
停止方式应用调用 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 用户手势要求

最佳实践

  1. 永远在用户点击「开始通话」「开启摄像头」等按钮后调用 getUserMedia
  2. 权限被拒绝后,引导用户到浏览器设置页手动开启,不要反复弹窗
  3. 使用 Permissions API 在 UI 上提前展示状态(摄像头图标灰色/绿色)
  4. 持久化权限:浏览器会记住 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

devicechange 只通知设备列表变化,不会自动停止或切换正在使用的 track。被拔出的设备对应的 track 会触发 onended,你需要同时监听两个事件。


八、跨浏览器兼容:adapter.js

不同浏览器对 WebRTC 的实现存在细微差异。在 WebRTC 标准化之前(参见 Curious 历史 中 Serge 描述的「每个子系统不同 API」困境),开发者需要处理 webkitGetUserMediamozGetUserMedia 等前缀。

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 浏览器差异速查

特性ChromeFirefoxSafari备注
getUserMedia均需 HTTPS
getDisplayMedia✅ 16+Safari 有限制
enumerateDevices label需先授权
setSinkId仅 Chromium
devicechange
facingMode移动端
applyConstraints部分因设备而异
本系列 Demo 策略

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()。确保在 beforeunloadpagehide 和组件 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 扩展挑战

  1. 权限状态 UI:用 navigator.permissions.query 在按钮旁显示摄像头/麦克风权限图标
  2. 能力自适应:读取 getCapabilities(),根据 max 值动态生成分辨率选项
  3. 轨道信息面板:实时展示每个 track 的 kindreadyStatemutedgetSettings()
  4. 错误恢复NotReadableError 时自动重试 3 次,每次间隔 1 秒
  5. 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(); // 设备插拔时刷新列表
});

十一、本章小结

要点内容
入口 APIgetUserMedia / getDisplayMedia / enumerateDevices
核心对象MediaStreamMediaStreamTrack,理解生命周期与事件
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信令服务器设计与会话状态机✅ 已发布
4SDP 解剖与媒体协商✅ 已发布
5ICE、STUN、TURN 与 NAT 穿透✅ 已发布
6Data Channel 与 SCTP over DTLS✅ 已发布
7DTLS 握手与 SRTP 加密体系✅ 已发布
8RTP/RTCP 媒体传输与 QoS✅ 已发布
9音视频编解码与 Simulcast 入门✅ 已发布
10带宽估计与拥塞控制 GCC✅ 已发布
11Simulcast、SVC 与选择性订阅✅ 已发布
12SFU/MCU/Mesh 架构与 Pion 实战✅ 已发布
13调试工具链与可观测性✅ 已发布
14TURN 集群部署与多区域扩展✅ 已发布
15Capstone 生产级视频会议系统✅ 已发布

References

Logo
RainLib

Exploring the frontiers of technology, design, and distributed systems. Building tools for the future developers.

Suggestions & Feedback

© 2026 RainLib. Built for the Future.
All rights reserved.
System Normal