7、媒体流控制:停止媒体流(stop()方法)、启用/禁用轨道(enabled属性)、动态切换设备

好,咱们今天聊点实在的。前面几章我们把摄像头和麦克风都打开了,画面也出来了,声音也听到了。但问题来了——你总不能一直开着吧?用户想关掉麦克风、想切换摄像头、想彻底释放资源,这些怎么搞?

这就是本章要解决的三个核心问题:停止媒体流、启用/禁用轨道、动态切换设备。说白了,就是怎么让用户把「水龙头」拧上、拧小、或者换个水龙头。

核心知识点速览

  • MediaStream.stop() —— 彻底关闭整个流,释放所有资源
  • MediaStreamTrack.enabled —— 临时禁用/启用某个轨道(比如静音麦克风)
  • 动态切换设备 —— 不重新创建流,直接换摄像头或麦克风
媒体流控制 停止媒体流 stream.stop() 启用/禁用轨道 track.enabled = true/false 动态切换设备 enumerateDevices() + replaceTrack() 释放摄像头/麦克风 停止指示灯/占用 静音/取消静音 视频黑屏/恢复 获取设备列表 replaceTrack() 替换 核心原则:用完即释放,临时用 enabled,换设备用 replaceTrack 不要频繁 stop() 再重新 getUserMedia(),性能差且体验不好

7.1 停止媒体流:stream.stop() 的正确用法

先说说最狠的操作——彻底关闭。当用户挂断电话、退出直播间、或者切换页面时,你得把摄像头和麦克风占用的资源还回去。不然的话,那个摄像头指示灯一直亮着,用户会慌的。

我记得有一次做视频会议项目,测试的时候发现退出房间后,摄像头灯还亮着。用户反馈说「你们是不是在偷看我?」——嗯,这锅我们得背。其实就是忘了调 stop()

注意: stream.stop() 这个方法在较新的浏览器中已经废弃了。现在推荐的做法是遍历所有轨道,逐个调用 track.stop()

正确的做法是这样的:

// 停止整个媒体流
function stopMediaStream(stream) {
  if (!stream) return;
  
  // 获取所有轨道
  const tracks = stream.getTracks();
  
  // 逐个停止
  tracks.forEach(track => {
    track.stop();
  });
  
  // 我个人习惯再加一步:清空引用
  stream = null;
}

你想想看,为什么不能直接调 stream.stop()?因为一个 MediaStream 对象可能包含多个轨道(比如音频+视频),而每个轨道是独立管理的。逐个停止,更精细,也更安全。

小技巧: 停止轨道后,最好把 <video><audio> 元素的 srcObject 也置空,避免残留画面。

videoElement.srcObject = null;

7.2 启用/禁用轨道:enabled 属性的妙用

有时候你并不想彻底关闭,只是想临时「静音」一下。比如开会时你咳嗽一声,想暂时关掉麦克风。这时候用 enabled 属性就对了。

enabledMediaStreamTrack 上的一个布尔属性。设为 false 时,轨道还在,但不再传输数据。说白了,就是「假装关了,其实没关」。

// 获取音频轨道
const audioTrack = stream.getAudioTracks()[0];

// 静音
audioTrack.enabled = false;

// 取消静音
audioTrack.enabled = true;

这里有个坑,我曾经踩过——enabledmuted 是两码事

属性 含义 谁控制的
enabled 是否启用轨道(主动控制) 开发者/用户
muted 是否被系统静音(被动状态) 系统/硬件

举个例子:你把 enabled 设为 false,麦克风图标变红,这是你主动操作的。但如果用户拔掉了耳机,系统会自动把 muted 设为 true,这是被动发生的。两者不冲突,但你要区分清楚。

实战场景: 视频通话中的「关闭摄像头」功能,其实就是把视频轨道的 enabled 设为 false。这时候画面会变黑,但连接还在。对方看到的是一张黑屏,而不是断开连接。

7.3 动态切换设备:不重建流,直接换

这是本章的重头戏。用户想从前置摄像头切换到后置摄像头,或者从耳机麦克风切换到外置麦克风。最笨的办法是:stop() 旧流 → getUserMedia() 新流 → 重新绑定。但这样会闪一下,用户体验很差。

更好的做法是:replaceTrack() 方法。它可以在不重建 RTCPeerConnection 的情况下,直接替换轨道。

流程是这样的:

  1. 调用 navigator.mediaDevices.enumerateDevices() 获取所有设备列表
  2. 用户选择新设备
  3. 用新设备重新 getUserMedia() 获取新轨道
  4. replaceTrack() 替换旧轨道
  5. 停止旧轨道
// 获取设备列表
async function getDevices() {
  const devices = await navigator.mediaDevices.enumerateDevices();
  
  // 只筛选摄像头
  const videoDevices = devices.filter(d => d.kind === 'videoinput');
  
  // 生成选项
  videoDevices.forEach((device, index) => {
    const option = document.createElement('option');
    option.value = device.deviceId;
    option.text = device.label || `摄像头 ${index + 1}`;
    selectElement.appendChild(option);
  });
}

// 切换摄像头
async function switchCamera(newDeviceId) {
  // 获取新轨道
  const newStream = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: newDeviceId } }
  });
  
  const newTrack = newStream.getVideoTracks()[0];
  
  // 替换旧轨道
  const oldTrack = localStream.getVideoTracks()[0];
  if (oldTrack) {
    // 如果是 WebRTC 连接,用 peerConnection 的 replaceTrack
    // 如果是本地预览,直接替换 video 元素的 srcObject
    localStream.removeTrack(oldTrack);
    localStream.addTrack(newTrack);
    oldTrack.stop();
  }
  
  // 更新预览
  videoElement.srcObject = localStream;
}

注意: replaceTrack()RTCRtpSender 上的方法,用于替换已发送的轨道。如果是本地预览,直接用 removeTrack() + addTrack() 就行。

嗯,这里要注意一个细节:设备标签(label)在用户授权前是空的。也就是说,如果你没有先调一次 getUserMedia()enumerateDevices() 返回的设备是没有名字的。我刚开始做的时候就被这个坑过——列表里全是「未知设备」,用户根本不知道选哪个。

解决方案?先调一次 getUserMedia() 获取权限,然后再枚举设备。或者,在用户点击「切换摄像头」按钮时,先请求权限。

我的习惯: 在页面加载时先调一次 getUserMedia({ video: true, audio: true }),然后立即 stop() 掉。这样既拿到了权限,又不会一直占用资源。之后再枚举设备,就能拿到完整的标签了。

7.4 综合实战:一个完整的媒体控制面板

来,我们把上面三个知识点串起来,做一个简单的控制面板。包含:开始/停止、静音/取消静音、切换摄像头。

class MediaController {
  constructor() {
    this.stream = null;
    this.videoElement = document.getElementById('video');
    this.audioEnabled = true;
    this.videoEnabled = true;
  }
  
  // 开始采集
  async start() {
    this.stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    this.videoElement.srcObject = this.stream;
  }
  
  // 停止采集
  stop() {
    if (this.stream) {
      this.stream.getTracks().forEach(track => track.stop());
      this.videoElement.srcObject = null;
      this.stream = null;
    }
  }
  
  // 切换音频
  toggleAudio() {
    const track = this.stream?.getAudioTracks()[0];
    if (track) {
      track.enabled = !track.enabled;
      this.audioEnabled = track.enabled;
    }
  }
  
  // 切换视频
  toggleVideo() {
    const track = this.stream?.getVideoTracks()[0];
    if (track) {
      track.enabled = !track.enabled;
      this.videoEnabled = track.enabled;
    }
  }
  
  // 切换摄像头
  async switchCamera(deviceId) {
    const newStream = await navigator.mediaDevices.getUserMedia({
      video: { deviceId: { exact: deviceId } }
    });
    
    const newTrack = newStream.getVideoTracks()[0];
    const oldTrack = this.stream?.getVideoTracks()[0];
    
    if (oldTrack) {
      this.stream.removeTrack(oldTrack);
      this.stream.addTrack(newTrack);
      oldTrack.stop();
    }
    
    this.videoElement.srcObject = this.stream;
  }
}

这个类封装了所有核心操作。你只需要在 UI 上绑定按钮事件就行。我个人觉得,这种封装方式比到处写散落的 getUserMedia 要清爽得多。

总结一下:

  • 彻底关闭track.stop(),记得逐个停止所有轨道
  • 临时禁用track.enabled = false,恢复时设为 true
  • 切换设备enumerateDevices() + replaceTrack(),避免重建流
  • 别忘了在停止后清空 srcObject,释放 DOM 引用

好了,这一章的内容就到这里。媒体流控制是 WebRTC 开发中非常基础但又容易出错的部分。你只要记住:该关的关,该静的静,该换的换,就不会出大问题。


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