webrtc之音视频通话实用功能

588 阅读8分钟

上一小节介绍了webrtc如何实现传输文件,非常简单,就和普通的上传文件一样。那么这节就再来补充几个必要的小功能,比如实现桌面共享、开启/关闭麦克风、使用上传的视频来替代摄像头和切换摄像头。

注意:这节内容都是在webrtc实现音视频通话的基础上进行拓展的!!!

OK,直接进入主题,先看看如何实现桌面共享。

桌面共享

首先,给出官方地址,想要深入了解的可以直接去看官网。这里我只会列出基础的、需要知道的一些东西。

先看api,如下:

partial interface MediaDevices {
  Promise<MediaStream> getDisplayMedia(optional DisplayMediaStreamOptions options = {});
};

// 使用
try {
  let mediaStream = await navigator.mediaDevices.getDisplayMedia();
  videoElement.srcObject = mediaStream;
} catch (e) {
  console.log('Unable to acquire screen capture: ' + e);
}

对于这个约束条件options,有如下默认值:

dictionary DisplayMediaStreamOptions {
  (boolean or MediaTrackConstraints) video = true;
  (boolean or MediaTrackConstraints) audio = false;
  CaptureController controller;
  SelfCapturePreferenceEnum selfBrowserSurface;
  SystemAudioPreferenceEnum systemAudio;
  SurfaceSwitchingPreferenceEnum surfaceSwitching;
  MonitorTypeSurfacesEnum monitorTypeSurfaces;
};

各个参数的大致解释如下(机翻): video: 如果为 true,则请求返回的 MediaStream 包含视频轨道。如果提供了约束结构,则它进一步指定要应用于用户选择的显示表面的视频轨道再现的所需处理选项。如果为 false,则根据 getDisplayMedia 算法,请求将被拒绝并出现 TypeError。

audio: 如果为 true,则表示有兴趣返回的 MediaStream 包含音轨(如果支持)并且音频可用于用户选择的显示表面。如果提供了约束结构,则它进一步指定要应用于音轨的所需处理选项。如果为 false,MediaStream 将不包含音轨。

controller: 如果存在,此 CaptureController 对象将与捕获会话关联。通过该对象上公开的方法,可以操纵捕获会话。

selfBrowserSurface: 如果存在,则指示应用程序偏好是否应将与该相关全局对象的关联文档的顶级浏览上下文关联的浏览器显示表面包含在提供给用户的选择之中。用户代理可以忽略此提示。

systemAudio: 如果存在,则指示应用程序是否希望将系统音频包含在提供给用户的可能音频源中。用户代理可以忽略此提示。

surfaceSwitching: 如果存在,则指示应用程序是否希望用户代理为用户提供动态切换捕获的显示表面的选项。用户代理可以忽略此提示。

monitorTypeSurfaces: 如果存在,则指示应用程序是否希望用户代理在提供给用户的选项中包含类型为监视器的显示表面。用户代理可以忽略此提示。

这里关注videoaudio就好了,参数可以为布尔值,也可以是像摄像头的约束条件一样。

再看看实操代码,如下:

<el-tooltip placement="top">
  <template #content
    >{{ btnState.localShare ? "开启" : "关闭" }}桌面共享</template
  >
  <SvgIcon
    name="桌面共享"
    @click="handleLocalShare"
    v-if="btnState.localShare"
  />
  <SvgIcon name="正在桌面共享" @click="handleLocalShare" v-else />
</el-tooltip>


// 获取本地屏幕共享
const getShareMediaStream = async () => {
  return await navigator.mediaDevices
    .getDisplayMedia()
    .catch(handleError);
};

const handleLocalShare = async () => {
  if (!localRtcPc.value) {
    ElMessage({
      type: "error",
      message: "请先建立 WebRTC 连接",
    });
    return;
  }

  btnState.localShare = !btnState.localShare;

  if (btnState.localShare) {
    // 停止屏幕共享
    const senders = localRtcPc.value.getSenders();
    const videoSender = senders.find((sender) => sender.track.kind === "video");
    const newStream = await getLocalUserMedia({ video: true, audio: true }); // 重新获取媒体流
    const [videoTrack] = newStream.getVideoTracks(); // 找出媒体流中的视频流
    if (videoSender) {
      videoSender.track.stop();
      videoSender.replaceTrack(videoTrack);
    } else {
      localRtcPc.value.addTrack(videoTrack, newStream);
    }
  } else {
    // 启动屏幕共享
    const stream = await getShareMediaStream();
    const [videoTrack] = stream.getVideoTracks();
    const senders = localRtcPc.value.getSenders();
    const videoSender = senders.find((sender) => sender.track.kind === "video");

    if (videoSender) {
      videoSender.replaceTrack(videoTrack);
    } else {
      localRtcPc.value.addTrack(videoTrack, stream);
    }



    // 停止屏幕共享 
    videoTrack.onended = async () => {
      btnState.localShare = true;
      const newStream = await getLocalUserMedia({ video: true, audio: true });
      const [videoTrack] = newStream.getVideoTracks();
      const senders = localRtcPc.value.getSenders();
      const videoSender = senders.find(
        (sender) => sender.track.kind === "video"
      );
      if (videoSender) {
        videoSender.replaceTrack(videoTrack);
      } else {
        localRtcPc.value.addTrack(videoTrack, newStream);
      }
    };
  }
};

启动桌面共享,无非就是先获取桌面的媒体流,然后替换掉发送器中的视频流,如果还选择了音频,则将发送器中的音频流也一同去掉。但是关闭桌面共享则有两种方式,一种是启动共享后,浏览器自带的控制桌面共享的按钮,此时需要监听视频流轨道的ended事件,在这个事件里面将关闭桌面共享并重置媒体流。另一种是自定义按钮来控制,点击后先将桌面的视频流全都停止,然后替换掉就行。

使用上传的视频

这个挺有意思,可以实现多种功能,比如采用本地摄像头的视频,然后嵌入部分上传的视频内容,达到混合效果;或者直接将摄像头中的人物或物体抠出来,嵌入到上传的视频中去,将上传的视频当成背景也是可以实现的。不过这两个都需要用到人物识别模型才能实现,这里先不介绍了,后续有时间再来整活。我们先实现最简单的,单纯的用上传的视频来替换掉本地摄像头的视频。

这里只用到了HTMLMediaElementcaptureStream()方法,这个方法返回一个 MediaStream 对象,该对象可以当成是实时捕获的媒体流,它可以用作 WebRTC RTCPeerConnection 的源。

官方地址在这里,使用示例如下:

document.querySelector(".playAndRecord").addEventListener("click", () => {
  const playbackElement = document.getElementById("playback");
  const captureStream = playbackElement.captureStream();
  playbackElement.play();
});

别看示例很简单,实际使用起来也是很简单,代码如下:

// html部分
<el-tooltip placement="top">
  <template #content
    >{{
      btnState.useExternalVideo ? "关闭" : "使用"
    }}外部视频</template
  >
  <SvgIcon
    name="外部视频"
    @click="handleExternalVideo"
    v-if="!btnState.useExternalVideo"
  />
  <SvgIcon
    name="正在使用外部视频"
    @click="handleExternalVideo"
    v-else
  />
</el-tooltip>

// 使用外部视频
const handleExternalVideo = async () => {
  btnState.useExternalVideo = !btnState.useExternalVideo;

  // 这里可拓展为 选取视频文件来播放
  if (btnState.useExternalVideo) {
    const _external = document.createElement("video");
    _external.src = externalVideoUrl;
    _external.controls = true;
    _external.autoplay = true;
    _external.muted = true;
    _external.loop = true;
    _external.play().catch((e) => {
      console.log("error", e);
      ElMessage({
        type: "error",
        message: "该视频播放失败,请重新尝试",
      });
    });

    if (_external.readyState < 3) {
      await new Promise((resolve) =>
        _external.addEventListener("canplay", resolve)
      );
    }

    let _externalVideoStream;

    // 兼容不同浏览器
    if (_external.captureStream)
      _externalVideoStream = _external.captureStream();
    else if (_external.mozCaptureStream)
      _externalVideoStream = _external.mozCaptureStream();
    else throw new Error("video.captureStream() not supported");

    console.log(_externalVideoStream, "_externalVideoStream------------------");

    videoRef.value.srcObject = _externalVideoStream; // 替换掉本地的

    if (localRtcPc.value && _externalVideoStream) {
      // 这里只是替换了视频流 没有替换音频流  可自行替换
      const senders = localRtcPc.value.getSenders();
      const videoSender = senders.find(
        (sender) => sender.track.kind === "video"
      );
      const [videoTrack] = _externalVideoStream.getVideoTracks();

      if (videoSender) {
        videoSender.replaceTrack(videoTrack);
      } else {
        localRtcPc.value.addTrack(videoTrack, _externalVideoStream);
      }
    }
  } else {
    const newStream = await getLocalUserMedia({ video: true, audio: true });
    videoRef.value.srcObject = newStream;
    videoRef.value.src = null;

    if (localRtcPc.value) {
      const [videoTrack] = newStream.getVideoTracks();
      const senders = localRtcPc.value.getSenders();
      const videoSender = senders.find(
        (sender) => sender.track.kind === "video"
      );
      if (videoSender) {
        videoSender.replaceTrack(videoTrack);
      } else {
        localRtcPc.value.addTrack(videoTrack, newStream);
      }
    }
  }
};

这里是写死的视频地址,然后创建出一个视频元素,并且获取到该视频元素的媒体流,替换掉摄像头的视频流。注意,还得替换掉本地的视频流。当然,实际使用时可以拓展为选取视频文件并上传,然后再使用。

可以看到,使用桌面共享或外部视频,最主要的点都是获取到媒体流,然后替换掉摄像头的媒体流,就这么简单。

关闭/开启 摄像头/麦克风

这里关闭/开启摄像头是指已经建立连接,但在某种场景下需要关闭/开启 摄像头/麦克风,这里有几种实现方案可以考虑:

先看关闭/开启摄像头:

  1. 从源头出发,直接修改约束条件,将videotrue改为false,重新获取媒体流再进行替换。这个方案有一个问题就是,本地也没视频了
  2. 从发送器出发,在发送的媒体流中去除掉视频流,这样本地还是有视频的,但远端则看不到了。这个方案挺好的
  3. 从远端播放端控制,即在远端接收到媒体流后,去除掉其中视频流。这个方案需要发送消息给远端,然后远端接收到消息后再进行控制,有点麻烦

再看关闭/开启麦克风:

  1. 从源头出发,直接修改约束条件,将audiotrue改为false,重新获取媒体流再进行替换。这个方案挺好的。
  2. 从发送器出发,在发送的媒体流中去除掉音频流,这样本地还是有音频的,这个方案挺好的。
  3. 从远端播放端控制,即在远端接收到媒体流后,去除掉其中音频流。这个方案需要发送消息给远端,然后远端接收到消息后再进行控制,有点麻烦。
  4. 从远端播放端控制,只要控制video标签中的muted属性即可,自然就能放不出声音了。

仔细看看,其实麦克风和摄像头的实现方案没啥区别,都是有三种方案。麦克风有第四种方案是因为video标签自带了能控制声音的属性,所以可以利用起来。当然,你也可以利用video标签中的暂停来控制摄像头的关闭/开启,但我没试过。

对于修改约束条件的,很简单,如下:

navigator.mediaDevices.getUserMedia(getUserMediaConstraints())
  .then(gotStream)
  .catch(e => {
    ...
  });

对于方案二和方案三,其实实现方法是一样的,代码如下:

//  这个是去除 音频, 去除视频类似
  localStream.getTracks().forEach((track)=>{
    if(track.kink !== 'audio') {
      pc.addTrack(track, localStream);
    }
  });

对于方案四就不说了,改个属性应该是简简单单的。

小节

本小节介绍了三种实用功能,分别是桌面共享和摄像头内容的切换、使用外部视频替代摄像头和关闭/开启摄像头/麦克风。这几个都是经常使用的功能,实现起来也没啥难度。对于 关闭/开启 摄像头/麦克风还介绍了几种实现方案,适应不同场景下的需求。

下一小节介绍如何使用虚拟背景,到这里就开始有趣了,冲!