3、媒体流基础:MediaStream与MediaStreamTrack详解、getUserMedia实战、屏幕共享

好,咱们今天聊点实在的。媒体流,说白了就是WebRTC世界里最基础的“水管”。你想想看,没有水,你修再漂亮的管道也没用。在WebRTC里,这个“水”就是音频和视频数据,而水管就是MediaStream和MediaStreamTrack。

我个人习惯把MediaStream想象成一个“轨道容器”。它本身不产生数据,但它负责把多个轨道(Track)组织在一起,统一管理。比如一个视频通话,通常包含一个音频Track和一个视频Track,它们被装进同一个MediaStream里,然后通过网络发送给对方。

MediaStream与MediaStreamTrack:谁是谁?

先搞清楚这两个概念,别搞混了。

  • MediaStream:一个容器,可以包含多个MediaStreamTrack。它有一个id属性,用来唯一标识这个流。
  • MediaStreamTrack:真正的数据源。它代表一个单独的媒体轨道,比如摄像头的一路视频,或者麦克风的一路音频。

嗯,这里要注意:一个MediaStream可以包含多个相同类型的Track吗?理论上可以,但实际项目中很少这么干。我见过有人试图在一个流里塞两个视频Track,结果接收端处理起来非常麻烦。我的建议是:一个流里只放一个视频Track和一个音频Track,简单清晰。

核心关系图

MediaStream 容器 MediaStreamTrack (视频) kind: "video" label: "FaceTime HD Camera" 状态: live / ended MediaStreamTrack (音频) kind: "audio" label: "Built-in Microphone" 状态: live / muted 同属一个流

getUserMedia实战:拿到你的第一滴水

getUserMedia是浏览器提供的一个API,用来获取用户的摄像头和麦克风权限。说白了,就是问用户:“嘿,我能用你的摄像头吗?”

最基本的用法是这样的:

// 请求视频和音频
async function startCamera() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    // 把视频流绑定到video元素上
    const videoElement = document.getElementById('localVideo');
    videoElement.srcObject = stream;
  } catch (err) {
    console.error('获取媒体流失败:', err);
  }
}

这段代码看起来简单,但坑不少。我曾经在一个项目中,用户反馈说摄像头打不开,排查了半天才发现是用户把摄像头权限给禁了。所以,一定要处理错误情况。

避坑指南

我曾经遇到过一个诡异的问题:在iOS Safari上,getUserMedia返回的stream是正常的,但视频就是黑屏。后来发现是因为没有在用户手势(比如点击按钮)的回调里调用getUserMedia。iOS Safari要求getUserMedia必须在用户交互事件中触发,否则会静默失败。嗯,这个坑我踩过,你们别踩了。

更精细的控制:约束条件

你不想随便拿个分辨率就完事吧?getUserMedia支持传入约束条件,让你指定想要的摄像头参数。

const constraints = {
  video: {
    width: { min: 640, ideal: 1280, max: 1920 },
    height: { min: 480, ideal: 720, max: 1080 },
    frameRate: { ideal: 30, max: 60 },
    facingMode: 'user'  // 前置摄像头
  },
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    sampleRate: 48000
  }
};

const stream = await navigator.mediaDevices.getUserMedia(constraints);

这里有个小技巧:ideal表示理想值,浏览器会尽量满足,但如果不满足,它会自动降级到最接近的值。min和max是硬性要求,如果浏览器无法满足,会直接报错。我个人习惯只设ideal,不设min和max,这样兼容性最好。

屏幕共享:getDisplayMedia

屏幕共享和摄像头采集不一样。摄像头采集用的是getUserMedia,屏幕共享用的是getDisplayMedia。为什么分开?因为屏幕共享涉及隐私,浏览器会弹出一个更严格的权限对话框,让用户选择共享整个屏幕、某个窗口还是某个浏览器标签页。

async function startScreenShare() {
  try {
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        cursor: 'always'  // 显示鼠标指针
      },
      audio: false  // 屏幕共享一般不采集音频
    });
    // 把屏幕共享流绑定到video元素
    const screenVideo = document.getElementById('screenVideo');
    screenVideo.srcObject = stream;
  } catch (err) {
    console.error('屏幕共享失败:', err);
  }
}

注意,getDisplayMedia返回的stream里,video track的kind仍然是"video",但它的label会变成类似"Entire Screen"或"Window Title"这样的描述。你可以通过track.label来判断用户共享的是什么。

个人经验

我在做远程协作工具时,发现屏幕共享的帧率不能设太高。设30fps的话,CPU占用会飙升,尤其是共享整个4K屏幕时。我建议屏幕共享用10-15fps就够了,用户看起来不会觉得卡,而且性能开销小很多。

Track的生命周期管理

拿到Track之后,你得知道怎么控制它。每个MediaStreamTrack都有几个关键方法:

方法/属性 说明 使用场景
track.enabled 启用或禁用轨道 静音/取消静音
track.stop() 停止轨道,释放资源 关闭摄像头
track.readyState 返回"live"或"ended" 检查轨道是否还在运行
track.clone() 克隆一个相同的轨道 需要同时发送给多个Peer

举个例子,你想实现一个“静音”按钮:

function toggleMute(stream) {
  const audioTrack = stream.getAudioTracks()[0];
  if (audioTrack) {
    audioTrack.enabled = !audioTrack.enabled;
    // 更新UI
    document.getElementById('muteBtn').textContent = 
      audioTrack.enabled ? '静音' : '取消静音';
  }
}

这里有个细节:enabled设为false时,轨道仍然存在,只是不输出数据。这比直接stop()要好,因为stop()之后轨道就彻底结束了,无法恢复。

从Stream中提取Track

有时候你需要单独操作某个Track。比如,你想把视频Track替换成屏幕共享的Track,但保留音频Track不变。

// 获取当前流的视频Track
const oldVideoTrack = localStream.getVideoTracks()[0];
// 获取屏幕共享流的视频Track
const screenTrack = screenStream.getVideoTracks()[0];

// 替换轨道
localStream.removeTrack(oldVideoTrack);
oldVideoTrack.stop();  // 关闭摄像头
localStream.addTrack(screenTrack);

// 然后通过RTCPeerConnection发送更新后的流
// 注意:需要重新negotiate

嗯,这里要注意:替换Track之后,你需要重新进行信令协商(renegotiation),否则对端不会知道你的Track变了。这个我在后面的章节会详细讲。

常见问题与调试技巧

最后,分享几个我调试时常用的方法:

  • 检查Track列表stream.getTracks() 返回所有Track,可以用来确认你拿到了哪些轨道。
  • 查看Track的labeltrack.label 可以告诉你这个Track来自哪个设备,比如"FaceTime HD Camera"或"Built-in Microphone"。
  • 监听ended事件:当用户拔掉摄像头或关闭屏幕共享时,Track会触发ended事件。你可以监听这个事件来做UI提示。
track.onended = () => {
  console.log('Track ended:', track.label);
  // 更新UI,提示用户设备已断开
};

好了,关于MediaStream和MediaStreamTrack的基础就聊这么多。记住,它们是WebRTC的基石,搞懂了这两个概念,后面的路就好走了。