3、媒体流获取:getUserMedia API详解、摄像头与麦克风控制、屏幕共享、媒体约束与适配。

各位同学,欢迎来到第三讲。

今天我们来聊聊整个WebRTC应用的起点——媒体流获取。说白了,就是怎么让浏览器拿到你的摄像头画面、麦克风声音,甚至是整个屏幕。

这部分我当年刚接触时踩过不少坑。比如有一次,用户反馈说“为什么我点了允许,但画面还是黑的?”——嗯,后来发现是约束条件写得太死,摄像头不支持那个分辨率。所以今天我会把getUserMedia的细节、摄像头麦克风的控制、屏幕共享,以及媒体约束的适配,一次性讲透。

核心知识点速览

  • getUserMedia 的调用方式与 Promise 链
  • MediaStream 对象的结构
  • 摄像头/麦克风的开关与切换
  • 屏幕共享的两种实现路径
  • 媒体约束的精细控制与设备适配
媒体流获取核心流程 getUserMedia 摄像头控制 分辨率 / 帧率 / 切换 麦克风控制 音量 / 静音 / 降噪 屏幕共享 getDisplayMedia 媒体约束与设备适配 理想值 / 精确值 / 回退策略

3.1 getUserMedia 的基本调用

先看最基础的用法。你只需要传入一个MediaStreamConstraints对象,它就会返回一个 Promise,resolve 后给你一个 MediaStream

// 最简单的调用:同时请求摄像头和麦克风
navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
})
.then(function(stream) {
  // 把 stream 赋值给 video 元素
  const video = document.getElementById('localVideo');
  video.srcObject = stream;
})
.catch(function(err) {
  console.error('获取媒体流失败:', err.name, err.message);
});

这里有个细节:video: true 表示“随便给我一个摄像头”。但实际项目中,我建议你永远不要只写 true。为什么?因为不同设备的默认摄像头可能不是你想要的——比如笔记本的物理摄像头开关被关闭,或者用户插了外接摄像头。我曾经在一个远程医疗项目里,就因为没指定摄像头,导致医生端一直显示的是红外摄像头,画面一片惨绿……

💡 个人习惯:我每次都会至少指定一个理想分辨率,比如 video: { width: { ideal: 1280 }, height: { ideal: 720 } }。这样浏览器会优先选择能支持该分辨率的摄像头,而不是随便给一个最低配的。

3.2 摄像头与麦克风的精细控制

拿到 MediaStream 之后,你可以通过 getVideoTracks()getAudioTracks() 拿到具体的轨道对象。每个轨道都有 enabled 属性和 stop() 方法。

// 假设 stream 已经获取成功
const videoTrack = stream.getVideoTracks()[0];
const audioTrack = stream.getAudioTracks()[0];

// 关闭摄像头(但保留轨道对象)
videoTrack.enabled = false;

// 彻底停止摄像头(释放资源)
videoTrack.stop();

// 静音麦克风
audioTrack.enabled = false;

嗯,这里要注意:enabled = false 只是“软关闭”,摄像头指示灯可能还亮着。如果你想彻底关掉摄像头,必须调用 stop()。我记得有一次做在线教育,学生端只关了 enabled,结果摄像头灯一直亮着,家长以为被监控了……后来我改成 stop() 才解决。

切换摄像头

多摄像头设备越来越常见。切换的核心思路是:先枚举所有设备,然后重新调用 getUserMedia 并指定 deviceId

// 枚举所有视频输入设备
navigator.mediaDevices.enumerateDevices()
  .then(function(devices) {
    const videoDevices = devices.filter(d => d.kind === 'videoinput');
    // 假设用户选择了第二个摄像头
    const targetDeviceId = videoDevices[1].deviceId;

    // 重新获取流
    return navigator.mediaDevices.getUserMedia({
      video: { deviceId: { exact: targetDeviceId } },
      audio: false
    });
  })
  .then(function(newStream) {
    // 替换本地视频
    localVideo.srcObject = newStream;
    // 别忘了停止旧流中的轨道
    // oldStream.getTracks().forEach(t => t.stop());
  });

⚠️ 避坑指南:我曾经在切换摄像头时忘记停止旧流,结果浏览器同时占用了两个摄像头,导致第二个摄像头无法启动。记住:每次切换前,先 stop 掉所有旧轨道

3.3 屏幕共享:getDisplayMedia

屏幕共享和摄像头获取是两套 API。屏幕共享使用 navigator.mediaDevices.getDisplayMedia(),它会弹出一个选择窗口,让用户选择共享整个屏幕、某个窗口或某个浏览器标签页。

// 发起屏幕共享
navigator.mediaDevices.getDisplayMedia({
  video: {
    cursor: 'always'  // 始终显示鼠标指针
  },
  audio: false  // 屏幕共享默认不带音频,除非你单独捕获
})
.then(function(screenStream) {
  // 把屏幕流显示在远程端
  remoteVideo.srcObject = screenStream;

  // 注意:屏幕共享的轨道停止后,需要做清理
  screenStream.getVideoTracks()[0].addEventListener('ended', () => {
    console.log('用户停止了屏幕共享');
    // 切换回摄像头画面
  });
})
.catch(function(err) {
  if (err.name === 'NotAllowedError') {
    console.log('用户取消了屏幕共享');
  }
});

这里有个关键点:屏幕共享流不能和摄像头流直接混合。如果你想同时显示摄像头和屏幕,需要创建两个独立的 RTCPeerConnection,或者使用 MediaStreamaddTrack 方法合并。我个人习惯是单独发送屏幕流,这样接收端可以自由切换布局。

屏幕共享 vs 摄像头:核心区别

特性getUserMediagetDisplayMedia
用户交互浏览器权限弹窗系统级选择器(窗口/标签页/屏幕)
音频支持支持麦克风不支持系统音频(需单独捕获)
分辨率由约束控制由屏幕本身决定
停止事件手动 stop()用户可主动停止,触发 ended 事件

3.4 媒体约束与设备适配

媒体约束是 getUserMedia 的灵魂。它分为三种类型:

  • 精确值 (exact):必须完全匹配,否则 Promise 会 reject。
  • 理想值 (ideal):浏览器会尽量满足,但如果不满足,会降级到最接近的值。
  • 范围值 (min / max):设定一个区间,浏览器在区间内选择最优值。
// 一个比较完善的约束示例
const constraints = {
  video: {
    width: { min: 640, ideal: 1280, max: 1920 },
    height: { min: 480, ideal: 720, max: 1080 },
    frameRate: { ideal: 30, max: 60 },
    facingMode: { ideal: 'user' },  // 优先前置摄像头
    deviceId: undefined  // 不指定,让浏览器自动选择
  },
  audio: {
    echoCancellation: { exact: true },  // 必须开启回声消除
    noiseSuppression: { ideal: true },
    sampleRate: { ideal: 48000 }
  }
};

你想想看,为什么要有 idealexact 的区别?因为不同设备的摄像头能力差异太大了。我做过一个跨平台会议应用,在 MacBook Pro 上 width: 1920 没问题,但换到一台老款 ThinkPad 上就直接报错。后来我全部改成 ideal,再也没出过问题。

🎯 适配策略:我建议先调用 enumerateDevices() 获取设备列表,然后根据设备能力动态生成约束。比如检测到只有 720p 摄像头,就不要强行要求 1080p。

常见约束组合速查表

场景推荐约束
高清视频通话video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } }
低带宽环境video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 } }
仅音频通话audio: { echoCancellation: true, noiseSuppression: true }, video: false
屏幕共享+标注video: { cursor: 'always' }, audio: false

3.5 错误处理与降级

媒体获取失败的原因五花八门:用户拒绝权限、设备被占用、不支持指定的分辨率……我建议你永远不要只写一个 catch,而是针对不同错误做不同处理。

navigator.mediaDevices.getUserMedia(constraints)
  .then(handleStream)
  .catch(function(err) {
    if (err.name === 'NotAllowedError') {
      // 用户拒绝了权限,引导用户手动开启
      showPermissionGuide();
    } else if (err.name === 'NotFoundError') {
      // 没有找到摄像头/麦克风
      fallbackToAudioOnly();
    } else if (err.name === 'NotReadableError') {
      // 设备被其他应用占用
      alert('请关闭其他正在使用摄像头的应用');
    } else {
      // 其他未知错误
      console.error('getUserMedia error:', err);
    }
  });

嗯,这里有个小技巧:如果摄像头获取失败,可以尝试降级到仅音频,或者提示用户检查物理开关。我曾经在 Chrome 上遇到过 NotReadableError,原因是用户同时打开了 Zoom 和我们的网页应用。后来我们在错误提示里加了一句“请关闭其他视频会议软件”,反馈好了很多。

总结一下本章核心

  • getUserMedia 是媒体流的入口,永远不要只传 true,要指定约束。
  • 摄像头和麦克风通过 enabledstop() 控制,切换设备前务必释放旧资源。
  • 屏幕共享用 getDisplayMedia,注意监听 ended 事件。
  • 媒体约束用 ideal 代替 exact 可以提高兼容性。
  • 错误处理要区分类型,给用户明确的引导。

好了,这一章的内容就到这里。媒体流是整个 WebRTC 的基石,把它搞扎实了,后面的信令、传输、渲染都会顺畅很多。下一章我们会深入信令服务器的设计与实现——不过那是后话了。

公众号:蓝海资料掘金营,微信 deep3321