信令与视频通话

WebRTC 允许在两个设备之间进行实时、点对点的媒体交换。连接是通过称为 **信令** 的发现和协商过程建立的。本教程将指导您完成建立双向视频通话的步骤。

WebRTC 是一种完全点对点的技术,用于实时交换音频、视频和数据,但有一个关键前提。必须进行某种形式的发现和媒体格式协商,如其他地方所述,以便在不同网络上的两个设备可以相互定位。此过程称为 **信令**,涉及两个设备都连接到一个第三方、双方同意的服务器。通过此第三方服务器,两个设备可以相互定位,并交换协商消息。

在本文中,我们将进一步增强 WebSocket 聊天(最初作为 WebSocket 文档的一部分创建,本文链接即将发布;它实际上尚未上线),以支持在用户之间打开双向视频通话。您可以 在 Glitch 上试用此示例,并且可以 重混示例 以对其进行实验。您还可以 查看 GitHub 上的完整项目

**注意:** 如果您在 Glitch 上试用示例,请注意对代码所做的任何更改都会立即重置任何连接。此外,还有一个短暂的超时时间段;Glitch 实例仅用于快速实验和测试。

信令服务器

在两个设备之间建立 WebRTC 连接需要使用 **信令服务器** 来解决如何通过互联网连接它们。信令服务器的作用是充当中介,让两个对等体找到并建立连接,同时尽可能减少对潜在私有信息的暴露。我们如何创建此服务器,信令过程究竟是如何工作的?

首先,我们需要信令服务器本身。WebRTC 不会为信令信息指定传输机制。您可以使用任何您喜欢的机制,从 WebSocketfetch(),再到信鸽,来交换两个对等体之间的信令信息。

需要注意的是,服务器不需要理解或解释信令数据内容。虽然它是 SDP,但这也不太重要:通过信令服务器传递的消息内容实际上是一个黑盒子。重要的是,当 ICE 子系统指示您将信令数据发送到另一个对等体时,您会这样做,而另一个对等体知道如何接收此信息并将其传递给自己的 ICE 子系统。您只需来回传递信息。内容对信令服务器来说根本不重要。

准备聊天服务器以进行信令

我们的 聊天服务器 使用 WebSocket APIJSON 字符串的形式在每个客户端和服务器之间发送信息。服务器支持多种消息类型来处理任务,例如注册新用户、设置用户名和发送公共聊天消息。

为了让服务器支持信令和 ICE 协商,我们需要更新代码。我们必须允许将消息定向到一个特定的用户,而不是广播给所有连接的用户,并确保不认识的消息类型被传递并传递,而服务器不需要知道它们是什么。这让我们可以使用同一个服务器发送信令消息,而不需要单独的服务器。

让我们看一下我们需要对聊天服务器进行的更改,以支持 WebRTC 信令。这在文件 chatserver.js 中。

首先是添加函数 sendToOneUser()。顾名思义,此函数将一个字符串化的 JSON 消息发送到特定用户名。

js
function sendToOneUser(target, msgString) {
  connectionArray.find((conn) => conn.username === target).send(msgString);
}

此函数遍历已连接用户的列表,直到找到与指定用户名匹配的用户,然后将消息发送给该用户。参数 msgString 是一个字符串化的 JSON 对象。我们本可以使其接收原始消息对象,但在本例中,以这种方式效率更高。由于消息已字符串化,因此我们可以直接发送它,无需进一步处理。connectionArray 中的每个条目都是一个 WebSocket 对象,因此我们可以直接调用其 send() 方法。

我们最初的聊天演示不支持将消息发送到特定用户。接下来的任务是更新主 WebSocket 消息处理程序以支持这样做。这涉及在 "connection" 消息处理程序末尾附近进行更改

js
if (sendToClients) {
  const msgString = JSON.stringify(msg);

  if (msg.target && msg.target.length !== 0) {
    sendToOneUser(msg.target, msgString);
  } else {
    for (const connection of connectionArray) {
      connection.send(msgString);
    }
  }
}

此代码现在查看待处理消息以查看它是否具有 target 属性。如果该属性存在,则它指定要将消息发送到的客户端的用户名,并且我们调用 sendToOneUser() 将消息发送给他们。否则,通过遍历连接列表并将消息发送到每个用户,该消息将广播给所有用户。

由于现有代码允许发送任意消息类型,因此不需要进行任何额外更改。我们的客户端现在可以将未知类型的消息发送到任何特定用户,让他们根据需要来回发送信令消息。

这就是我们需要在服务器端进行的所有更改。现在让我们考虑一下我们将实现的信令协议。

设计信令协议

现在我们已经建立了一种交换消息的机制,我们需要一个定义这些消息外观的协议。这可以通过多种方式完成;此处演示的只是一种可能的信令消息结构方式。

此示例的服务器使用字符串化的 JSON 对象与客户端通信。这意味着我们的信令消息将以 JSON 格式,其内容指定它们是什么类型的消息以及处理这些消息正确所需的任何其他信息。

交换会话描述

在启动信令过程时,将由发起呼叫的用户创建 **提议**。此提议包括一个会话描述,以 SDP 格式,并且需要传递给接收用户,我们将称其为 **被呼叫者**。被呼叫者将以 **应答** 消息响应提议,其中也包含 SDP 描述。我们的信令服务器将使用 WebSocket 传输类型为 "video-offer" 的提议消息,以及类型为 "video-answer" 的应答消息。这些消息具有以下字段

类型

消息类型;"video-offer""video-answer"

名称

发送者的用户名。

目标

接收描述的人的用户名(如果呼叫者正在发送消息,则它指定被呼叫者,反之亦然)。

sdp

描述从发送者角度看连接本地端(或从接收者角度看连接远程端)的 SDP(会话描述协议)字符串。

此时,两个参与者知道要使用哪些 编解码器编解码器参数 来进行此通话。但是,他们仍然不知道如何传输媒体数据本身。这就是 交互式连接建立 (ICE) 的作用所在。

交换 ICE 候选

两个对等体需要交换 ICE 候选来协商它们之间的实际连接。每个 ICE 候选描述了发送对等体可以用来通信的一种方法。每个对等体按照发现的顺序发送候选,并一直发送候选,直到它用完建议,即使媒体已经开始流式传输也是如此。

当使用 pc.setLocalDescription(offer) 完成添加本地描述的过程时,会向 RTCPeerConnection 发送 icecandidate 事件。

一旦两个对等体就一个相互兼容的候选达成一致,每个对等体就会使用该候选的 SDP 来构建和打开一个连接,媒体随后开始流经该连接。如果它们随后就一个更好的(通常是性能更高的)候选达成一致,流可能会根据需要更改格式。

尽管目前不支持,但理论上也可以使用在媒体已经流式传输后收到的候选来降级到更低带宽的连接,如果需要的话。

通过向信令服务器发送类型为 "new-ice-candidate" 的 JSON 消息,并将该消息发送到远程对等体,将每个 ICE 候选发送到另一个对等体。每个候选消息包含以下字段

类型

消息类型:"new-ice-candidate"

目标

正在进行协商的人的用户名;服务器只会将该消息发送给此用户。

候选

SDP 候选字符串,描述了提议的连接方法。您通常不需要查看此字符串的内容。您的所有代码需要做的就是使用信令服务器将其路由到远程对等体。

每个 ICE 消息都会建议一种通信协议(TCP 或 UDP)、IP 地址、端口号、连接类型(例如,指定的 IP 是对等体本身还是中继服务器),以及将两台计算机连接在一起所需的其它信息。这包括 NAT 或其它网络复杂性。

注意:需要注意的是:在 ICE 协商过程中,您的代码唯一需要负责的是接收来自 ICE 层的传出候选,并在您的 onicecandidate 处理程序执行时将它们通过信令连接发送到另一个对等体,以及接收来自信令服务器的 ICE 候选消息(当收到 "new-ice-candidate" 消息时),并通过调用 RTCPeerConnection.addIceCandidate() 将它们传递给您的 ICE 层。仅此而已。

SDP 的内容在几乎所有情况下都与您无关。避免试图使其变得比这更复杂,除非您真的知道自己在做什么。那样做会让你发疯。

您的信令服务器现在只需要发送它被要求发送的消息即可。您的工作流程也可能需要登录/身份验证功能,但这些细节会有所不同。

注意:onicecandidate 事件和 createAnswer() Promise 都是异步调用,它们分别处理。请确保您的信令不会改变顺序!例如,使用服务器的冰候选的 addIceCandidate() 必须在使用 setRemoteDescription() 设置答案之后调用。

信令事务流

信令过程涉及两个对等体之间使用中介信令服务器交换消息。当然,确切的过程会有所不同,但总的来说,在信令消息得到处理时,有一些关键点

  • 每个用户在网络浏览器中运行的客户端
  • 每个用户的网络浏览器
  • 信令服务器
  • 托管聊天服务的网络服务器

想象一下,Naomi 和 Priya 正在使用聊天软件进行讨论,Naomi 决定在两人之间进行视频通话。以下是预期的事件顺序

Diagram of the signaling process

我们将在本文中详细介绍这一点。

ICE 候选交换过程

当每个对等体的 ICE 层开始发送候选时,它会进入链中各个点之间的交换,如下所示

Diagram of ICE candidate exchange process

每个端点在从其本地 ICE 层接收候选时,就会将其发送到另一端;没有轮流发送或对候选进行批处理。一旦两个对等体就一个候选达成一致,它们都可以使用该候选来交换媒体,媒体就开始流式传输。每个对等体都会继续发送候选,直到它用完所有选项,即使媒体已经开始流式传输也是如此。这样做是为了希望能找到比最初选择的选项更好的选项。

如果条件发生变化(例如,网络连接恶化),一个或两个对等体可能会建议切换到更低带宽的媒体分辨率,或者切换到另一种编解码器。这会触发一个新的候选交换,随后可能会发生另一种媒体格式和/或编解码器更改。在指南 WebRTC 使用的编解码器 中,您可以了解有关 WebRTC 要求浏览器支持的编解码器、哪些浏览器支持哪些额外编解码器以及如何选择要使用的最佳编解码器的更多信息。

可选地,请参阅 RFC 8445:交互式连接建立第 2.3 节(“协商候选对并结束 ICE”),如果您想更深入地了解此过程如何在 ICE 层内完成。您应该注意,候选会交换,并且媒体会在 ICE 层满足后立即开始流式传输。所有这些都在幕后完成。我们的作用是通过信令服务器来回发送候选。

客户端应用程序

任何信令过程的核心都是其消息处理。没有必要使用 WebSockets 进行信令,但它是一种常见的解决方案。当然,您应该为交换信令信息选择适合您应用程序的机制。

让我们更新聊天客户端以支持视频通话。

更新 HTML

客户端的 HTML 需要一个用于显示视频的位置。这需要视频元素和一个挂断电话的按钮

html
<div class="flexChild" id="camera-container">
  <div class="camera-box">
    <video id="received_video" autoplay></video>
    <video id="local_video" autoplay muted></video>
    <button id="hangup-button" onclick="hangUpCall();" disabled>Hang Up</button>
  </div>
</div>

此处定义的页面结构使用 <div> 元素,通过启用 CSS 的使用,我们可以完全控制页面布局。在本指南中,我们将跳过布局细节,但请 查看 GitHub 上的 CSS,了解我们如何处理它。请注意两个 <video> 元素,一个用于自视图,另一个用于连接,以及 <button> 元素。

id 为 "received_video" 的 <video> 元素将显示从连接用户那里接收到的视频。我们指定了 autoplay 属性,确保视频开始到达后立即播放。这样,我们就不需要在代码中显式处理播放。 "local_video" <video> 元素显示用户相机的预览;指定 muted 属性,因为我们不需要在这个预览面板中听到本地音频。

最后,定义了 "hangup-button" <button>,用于断开通话,并将其配置为从禁用开始(将其设置为没有通话连接时的默认值),并在单击时应用函数 hangUpCall()。此函数的作用是关闭通话,并向信令服务器发送通知,请求另一个对等体也关闭通话。

JavaScript 代码

我们将把这段代码分成功能区域,以便更轻松地描述其工作原理。这段代码的主体位于 connect() 函数中:它在端口 6503 上打开一个 WebSocket 服务器,并建立一个处理程序来接收 JSON 对象格式的消息。这段代码通常像以前一样处理文本聊天消息。

向信令服务器发送消息

在我们的代码中,我们调用 sendToServer() 来向信令服务器发送消息。此函数使用 WebSocket 连接来完成其工作

js
function sendToServer(msg) {
  const msgJSON = JSON.stringify(msg);

  connection.send(msgJSON);
}

传递到此函数的消息对象通过调用 JSON.stringify() 转换为 JSON 字符串,然后我们调用 WebSocket 连接的 send() 函数将该消息传输到服务器。

启动通话的 UI

处理 "userlist" 消息的代码会调用 handleUserlistMsg()。在这里,我们为用户列表中显示的每个连接用户设置了处理程序,该用户列表显示在聊天面板的左侧。此函数接收一个消息对象,该消息对象的 users 属性是一个字符串数组,指定每个连接用户的用户名。

js
function handleUserlistMsg(msg) {
  const listElem = document.querySelector(".userlistbox");

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  msg.users.forEach((username) => {
    const item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite, false);

    listElem.appendChild(item);
  });
}

在将包含用户名列表的 <ul> 的引用获取到变量 listElem 中后,我们通过删除其每个子元素来清空该列表。

注意:显然,通过添加和删除单个用户来更新列表,而不是每次列表更改时都重建整个列表,效率会更高,但这对于这个例子的目的来说已经足够好了。

然后,我们使用 forEach() 遍历用户名数组。对于每个名称,我们创建一个新的 <li> 元素,然后使用 createTextNode() 创建一个包含用户名的文本节点。该文本节点将作为 <li> 元素的子节点添加。接下来,我们为列表项上的 click 事件设置一个处理程序,点击用户名会调用我们的 invite() 方法,我们将在下一节中介绍它。

最后,我们将新项目附加到包含所有用户名的 <ul> 中。

启动通话

当用户点击他们要呼叫的用户名时,invite() 函数作为该 click 事件的事件处理程序被调用

js
const mediaConstraints = {
  audio: true, // We want an audio track
  video: true, // And we want a video track
};

function invite(evt) {
  if (myPeerConnection) {
    alert("You can't start a call because you already have one open!");
  } else {
    const clickedUsername = evt.target.textContent;

    if (clickedUsername === myUsername) {
      alert(
        "I'm afraid I can't let you talk to yourself. That would be weird.",
      );
      return;
    }

    targetUsername = clickedUsername;
    createPeerConnection();

    navigator.mediaDevices
      .getUserMedia(mediaConstraints)
      .then((localStream) => {
        document.getElementById("local_video").srcObject = localStream;
        localStream
          .getTracks()
          .forEach((track) => myPeerConnection.addTrack(track, localStream));
      })
      .catch(handleGetUserMediaError);
  }
}

这首先进行一个基本的健全性检查:用户是否已经连接?如果已经有 RTCPeerConnection,他们显然无法打电话。然后,从事件目标的 textContent 属性中获取被点击的用户的名字,并确保它不是试图发起通话的同一个用户。

然后,我们将要呼叫的用户的名称复制到变量 targetUsername 中,并调用 createPeerConnection(),这是一个函数,它将创建并进行 RTCPeerConnection 的基本配置。

创建 RTCPeerConnection 后,我们通过调用 MediaDevices.getUserMedia() 请求访问用户的摄像头和麦克风,该方法通过 MediaDevices.getUserMedia 属性提供给我们。当此操作成功并完成返回的 Promise 时,我们的 then 处理程序将执行。它以 MediaStream 对象作为输入,该对象表示包含来自用户麦克风的音频和来自用户网络摄像头的视频的流。

注意:我们可以通过调用 navigator.mediaDevices.enumerateDevices() 获取设备列表,根据我们的所需条件过滤结果列表,然后使用选定设备的 deviceId 值在传递给 getUserMedia()mediaConstraints 对象的 deviceId 字段中,将允许的媒体输入集限制为特定设备或设备集。实际上,这很少有必要,因为大多数工作由 getUserMedia() 为您完成。

我们将传入的流附加到本地预览 <video> 元素,方法是设置元素的 srcObject 属性。由于该元素配置为自动播放传入的视频,因此流将在我们的本地预览框中开始播放。

然后,我们遍历流中的轨道,调用 addTrack() 将每个轨道添加到 RTCPeerConnection。即使连接尚未完全建立,您也可以在认为合适的时候开始发送数据。在 ICE 协商完成之前接收的媒体可用于帮助 ICE 确定最佳的连接方法,从而帮助协商过程。

请注意,对于原生应用程序(例如手机应用程序),您应该在连接在两端都至少被接受之前开始发送,以避免在用户没有准备好的情况下无意中发送视频和/或音频数据。

只要媒体附加到 RTCPeerConnection,连接就会触发一个 negotiationneeded 事件,以便可以启动 ICE 协商。

如果在尝试获取本地媒体流时出现错误,我们的 catch 子句将调用 handleGetUserMediaError(),该函数根据需要向用户显示适当的错误。

处理 getUserMedia() 错误

如果 getUserMedia() 返回的 Promise 失败,则我们的 handleGetUserMediaError() 函数将执行。

js
function handleGetUserMediaError(e) {
  switch (e.name) {
    case "NotFoundError":
      alert(
        "Unable to open your call because no camera and/or microphone" +
          "were found.",
      );
      break;
    case "SecurityError":
    case "PermissionDeniedError":
      // Do nothing; this is the same as the user canceling the call.
      break;
    default:
      alert(`Error opening your camera and/or microphone: ${e.message}`);
      break;
  }

  closeVideoCall();
}

除一种情况外,在所有情况下都会显示一条错误消息。在本示例中,我们忽略了 "SecurityError""PermissionDeniedError" 结果,将拒绝授予使用媒体硬件的权限与用户取消呼叫视为相同。

无论尝试获取流失败的原因是什么,我们都会调用 closeVideoCall() 函数来关闭 RTCPeerConnection,并释放尝试呼叫过程中已分配的任何资源。此代码旨在安全地处理部分启动的呼叫。

创建对等连接

createPeerConnection() 函数由呼叫者和被呼叫者用来构建他们的 RTCPeerConnection 对象,即 WebRTC 连接各自的端点。它由 invite() 在呼叫者尝试启动呼叫时调用,并由 handleVideoOfferMsg() 在被呼叫者从呼叫者处收到提议消息时调用。

js
function createPeerConnection() {
  myPeerConnection = new RTCPeerConnection({
    iceServers: [
      // Information about ICE servers - Use your own!
      {
        urls: "stun:stun.stunprotocol.org",
      },
    ],
  });

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.ontrack = handleTrackEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.onremovetrack = handleRemoveTrackEvent;
  myPeerConnection.oniceconnectionstatechange =
    handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange =
    handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
}

使用 RTCPeerConnection() 构造函数时,我们将指定一个对象,该对象为连接提供配置参数。在本示例中,我们只使用其中一个:iceServers。这是一个包含用于 ICE 层的对象数组,用于描述在尝试建立呼叫者和被呼叫者之间的路由时要使用的 STUN 和/或 TURN 服务器。即使这些对等方位于防火墙后或使用 NAT,这些服务器也被用来确定在对等方之间通信时要使用的最佳路由和协议。

注意:您应该始终使用您拥有的或您有权使用的 STUN/TURN 服务器。本示例使用的是一个已知的公共 STUN 服务器,但滥用它们是不好的做法。

iceServers 中的每个对象至少包含一个 urls 字段,该字段提供可以联系到指定服务器的 URL。它还可以提供 usernamecredential 值,以便在需要时进行身份验证。

创建 RTCPeerConnection 后,我们为对我们重要的事件设置处理程序。

前三个事件处理程序是必需的;您必须处理它们才能执行与 WebRTC 流媒体相关的任何操作。其余的不是严格要求的,但可能有用,我们将探讨它们。还有一些其他事件可用,但我们不在本示例中使用。以下是我们将实现的每个事件处理程序的摘要

onicecandidate

当本地 ICE 层需要您通过信号服务器将 ICE 候选者传输到另一个对等方时,它会调用您的 icecandidate 事件处理程序。有关更多信息并查看本示例的代码,请参阅 发送 ICE 候选者

ontrack

当轨道添加到连接时,本地 WebRTC 层会调用此 track 事件的处理程序。这使您可以将传入的媒体连接到元素以显示它,例如。有关详细信息,请参阅 接收新的流

onnegotiationneeded

无论何时 WebRTC 基础设施需要您重新启动会话协商过程,都会调用此函数。它的作用是创建并发送一个提议给被呼叫者,要求它与我们连接。有关如何处理此问题的说明,请参阅 开始协商

onremovetrack

ontrack 的对应项,用于处理 removetrack 事件;当远程对等方从发送的媒体中删除轨道时,它会发送到 RTCPeerConnection。有关详细信息,请参阅 处理轨道的移除

oniceconnectionstatechange

ICE 层发送 iceconnectionstatechange 事件,以让您了解 ICE 连接状态的变化。这可以帮助您知道连接何时失败或断开连接。我们将查看下面 ICE 连接状态 中本示例的代码。

onicegatheringstatechange

当 ICE 代理收集候选者的过程从一个状态转变为另一个状态(例如,开始收集候选者或完成协商)时,ICE 层会向您发送 icegatheringstatechange 事件。有关详细信息,请参阅下面 ICE 收集状态

onsignalingstatechange

当信号过程的状态发生变化(或连接到信号服务器发生变化)时,WebRTC 基础设施会向您发送 signalingstatechange 消息。有关我们代码的说明,请参阅 信号状态

开始协商

呼叫者创建了其 RTCPeerConnection、创建了媒体流并将轨道添加到连接中(如 启动呼叫 中所示)后,浏览器会将 negotiationneeded 事件传递到 RTCPeerConnection,以指示它已准备好开始与另一个对等方进行协商。以下是我们处理 negotiationneeded 事件的代码

js
function handleNegotiationNeededEvent() {
  myPeerConnection
    .createOffer()
    .then((offer) => myPeerConnection.setLocalDescription(offer))
    .then(() => {
      sendToServer({
        name: myUsername,
        target: targetUsername,
        type: "video-offer",
        sdp: myPeerConnection.localDescription,
      });
    })
    .catch(reportError);
}

要启动协商过程,我们需要创建并发送一个 SDP 提议到我们要连接的对等方。此提议包含连接支持的配置列表,包括有关我们已在本地添加到连接的媒体流的信息(即,我们要发送到呼叫另一端的视频)以及 ICE 层已收集的任何 ICE 候选者。我们通过调用 myPeerConnection.createOffer() 来创建此提议。

createOffer() 成功(完成 Promise)时,我们将创建的提议信息传递到 myPeerConnection.setLocalDescription(),该方法配置连接和媒体配置状态,以呼叫者的连接端点。

注意:从技术上讲,createOffer() 返回的字符串是 RFC 3264 提议。

我们知道描述是有效的,并且已设置,因为 setLocalDescription() 返回的 Promise 已完成。此时,我们通过创建一个包含本地描述(现在与提议相同)的新 "video-offer" 消息,然后将其通过我们的信号服务器发送到被呼叫者,将我们的提议发送到另一个对等方。提议具有以下成员

类型

消息类型:"video-offer"

名称

呼叫者的用户名。

目标

我们要呼叫的用户的姓名。

sdp

描述提议的 SDP 字符串。

如果在初始 createOffer() 或任何后续完成处理程序中出现错误,则通过调用我们的 reportError() 函数报告错误。

setLocalDescription() 的完成处理程序运行后,ICE 代理开始向 RTCPeerConnection 发送 icecandidate 事件,每个事件对应于它发现的每个潜在配置。我们对 icecandidate 事件的处理程序负责将候选者传输到另一个对等方。

会话协商

现在我们已经开始与另一个对等方进行协商并传输了提议,让我们看一下一段时间内连接的被呼叫者一方发生了什么。被呼叫者接收提议并调用 handleVideoOfferMsg() 函数来处理它。让我们看看被呼叫者如何处理 "video-offer" 消息。

处理邀请

提议到达时,被呼叫者的 handleVideoOfferMsg() 函数将使用接收到的 "video-offer" 消息被调用。此函数需要做两件事。首先,它需要创建自己的 RTCPeerConnection 并向其中添加包含来自其麦克风和网络摄像头的音频和视频的轨道。其次,它需要处理接收到的提议,构造并发送其答案。

js
function handleVideoOfferMsg(msg) {
  let localStream = null;

  targetUsername = msg.name;
  createPeerConnection();

  const desc = new RTCSessionDescription(msg.sdp);

  myPeerConnection
    .setRemoteDescription(desc)
    .then(() => navigator.mediaDevices.getUserMedia(mediaConstraints))
    .then((stream) => {
      localStream = stream;
      document.getElementById("local_video").srcObject = localStream;

      localStream
        .getTracks()
        .forEach((track) => myPeerConnection.addTrack(track, localStream));
    })
    .then(() => myPeerConnection.createAnswer())
    .then((answer) => myPeerConnection.setLocalDescription(answer))
    .then(() => {
      const msg = {
        name: myUsername,
        target: targetUsername,
        type: "video-answer",
        sdp: myPeerConnection.localDescription,
      };

      sendToServer(msg);
    })
    .catch(handleGetUserMediaError);
}

此代码与我们在 启动呼叫 中的 invite() 函数中所做的非常相似。它首先使用我们的 createPeerConnection() 函数创建并配置一个 RTCPeerConnection。然后它从接收到的 "video-offer" 消息中获取 SDP 提议,并使用它来创建一个新的 RTCSessionDescription 对象,表示呼叫者的会话描述。

然后将该会话描述传递到 myPeerConnection.setRemoteDescription()。这将接收到的提议设置为连接远程(呼叫者)端的描述。如果操作成功,Promise 完成处理程序(在 then() 子句中)将启动使用 getUserMedia() 获取对被呼叫者的摄像头和麦克风的访问权限的过程,将轨道添加到连接中,等等,如我们之前在 invite() 中所见。

一旦使用 myPeerConnection.createAnswer() 创建了答案,连接本地端的描述将通过调用 myPeerConnection.setLocalDescription() 设置为答案的 SDP,然后答案将通过信令服务器传输给呼叫者,让他们知道答案是什么。

任何错误都会被捕获并传递给 handleGetUserMediaError(),该函数在 处理 getUserMedia() 错误 中进行了描述。

注意: 与呼叫者一样,一旦 setLocalDescription() 完成处理程序运行,浏览器就开始触发 icecandidate 事件,被呼叫者必须处理这些事件,每个事件对应一个需要传输到远程对等方的候选者。

最后,呼叫者通过创建一个新的 RTCSessionDescription 对象来处理它收到的答案消息,该对象代表被呼叫者的会话描述,并将其传递给 myPeerConnection.setRemoteDescription()

js
function handleVideoAnswerMsg(msg) {
  const desc = new RTCSessionDescription(msg.sdp);
  myPeerConnection.setRemoteDescription(desc).catch(reportError);
}
发送 ICE 候选者

ICE 协商过程涉及每个对等方反复向对方发送候选者,直到它用尽了它可以支持 RTCPeerConnection 的媒体传输需求的潜在方式。由于 ICE 不了解您的信令服务器,因此您的代码在 icecandidate 事件的处理程序中处理每个候选者的传输。

您的 onicecandidate 处理程序接收一个事件,该事件的 candidate 属性是描述候选者的 SDP(或者为 null,表示 ICE 层已用尽潜在配置来建议)。candidate 的内容是您需要使用信令服务器传输的内容。以下是我们示例的实现

js
function handleICECandidateEvent(event) {
  if (event.candidate) {
    sendToServer({
      type: "new-ice-candidate",
      target: targetUsername,
      candidate: event.candidate,
    });
  }
}

这将构建一个包含候选者的对象,然后使用前面在 向信令服务器发送消息 中描述的 sendToServer() 函数将其发送到另一个对等方。消息的属性是

类型

消息类型:"new-ice-candidate"

目标

需要向其传递 ICE 候选者的用户名。这使信令服务器能够路由消息。

候选

表示 ICE 层想要传输到另一个对等方的候选者的 SDP。

此消息的格式(与您在处理信令时所做的一切一样)完全由您决定,具体取决于您的需求;您可以根据需要提供其他信息。

注意: 重要的是要注意,icecandidate 事件不会在 ICE 候选者从呼叫的另一端到达时发送。相反,它们是由您呼叫的这一端发送的,以便您可以承担通过您选择的任何通道传输数据的任务。当您刚接触 WebRTC 时,这可能会令人困惑。

接收 ICE 候选者

信令服务器使用它选择的任何方法将每个 ICE 候选者传递给目标对等方;在我们的示例中,它是作为 JSON 对象,其中包含一个 type 属性,该属性包含字符串 "new-ice-candidate"。我们的 handleNewICECandidateMsg() 函数由我们的主要 WebSocket 入站消息代码调用来处理这些消息

js
function handleNewICECandidateMsg(msg) {
  const candidate = new RTCIceCandidate(msg.candidate);

  myPeerConnection.addIceCandidate(candidate).catch(reportError);
}

此函数通过将接收到的 SDP 传递到其构造函数中来构建一个 RTCIceCandidate 对象,然后将候选者传递给 ICE 层,方法是将其传递到 myPeerConnection.addIceCandidate()。这将新的 ICE 候选者传递给本地 ICE 层,最后,我们处理此候选者的流程就完成了。

每个对等方都向另一个对等方发送一个候选者,用于每个可能的传输配置,它认为这些配置可能对正在交换的媒体有效。在某个时候,这两个对等方会同意一个给定的候选者是一个不错的选择,然后它们打开连接并开始共享媒体。但是,重要的是要注意,ICE 协商不会在媒体开始流式传输后停止。相反,在对话开始后,候选者可能仍然会继续交换,无论是试图找到更好的连接方法,还是因为它们在对等方成功建立连接时已经在传输中。

此外,如果发生导致流式传输场景发生变化的情况,协商将重新开始,negotiationneeded 事件将发送到 RTCPeerConnection,并且整个过程将像之前描述的那样重新开始。这可能发生在各种情况下,包括

  • 网络状态的变化,例如带宽变化,从 Wi-Fi 切换到蜂窝网络连接,等等。
  • 在手机上切换前后摄像头。
  • 流配置的更改,例如其分辨率或帧速率。
接收新流

当新的轨道添加到 RTCPeerConnection 时(无论是通过调用其 addTrack() 方法还是由于流格式的重新协商),对于添加到连接的每个轨道,都会将一个 track 事件设置为 RTCPeerConnection。要利用新添加的媒体,需要为 track 事件实现一个处理程序。一个常见的需求是将传入的媒体附加到适当的 HTML 元素。在我们的示例中,我们将轨道的流添加到显示传入视频的 <video> 元素

js
function handleTrackEvent(event) {
  document.getElementById("received_video").srcObject = event.streams[0];
  document.getElementById("hangup-button").disabled = false;
}

传入的流附加到 "received_video" <video> 元素,并且“挂断” <button> 元素被启用,以便用户可以挂断电话。

一旦此代码完成,另一个对等方发送的视频最终将在本地浏览器窗口中显示!

处理轨道的移除

当远程对等方通过调用 RTCPeerConnection.removeTrack() 从连接中移除一个轨道时,您的代码会收到一个 removetrack 事件。我们对 "removetrack" 的处理程序是

js
function handleRemoveTrackEvent(event) {
  const stream = document.getElementById("received_video").srcObject;
  const trackList = stream.getTracks();

  if (trackList.length === 0) {
    closeVideoCall();
  }
}

此代码从 "received_video" <video> 元素的 srcObject 属性中获取传入的视频 MediaStream,然后调用流的 getTracks() 方法来获取流轨道的数组。

如果数组的长度为零,这意味着流中没有剩余轨道,我们将通过调用 closeVideoCall() 来结束通话。这将干净地将我们的应用程序恢复到可以开始或接收另一个通话的状态。请参阅 结束通话 以了解 closeVideoCall() 的工作原理。

结束通话

通话结束的原因有很多。通话可能已完成,一方或双方可能已挂断。也许发生了网络故障,或者一个用户可能退出了他们的浏览器,或者系统崩溃了。无论如何,所有美好的事物都必须结束。

挂断

当用户点击“挂断”按钮来结束通话时,将调用 hangUpCall() 函数

js
function hangUpCall() {
  closeVideoCall();
  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "hang-up",
  });
}

hangUpCall() 执行 closeVideoCall() 来关闭并重置连接并释放资源。然后它构建一个 "hang-up" 消息并将其发送到通话的另一端,以告诉另一个对等方干净地关闭自身。

结束通话

下面显示的 closeVideoCall() 函数负责停止流、清理和处置 RTCPeerConnection 对象

js
function closeVideoCall() {
  const remoteVideo = document.getElementById("received_video");
  const localVideo = document.getElementById("local_video");

  if (myPeerConnection) {
    myPeerConnection.ontrack = null;
    myPeerConnection.onremovetrack = null;
    myPeerConnection.onremovestream = null;
    myPeerConnection.onicecandidate = null;
    myPeerConnection.oniceconnectionstatechange = null;
    myPeerConnection.onsignalingstatechange = null;
    myPeerConnection.onicegatheringstatechange = null;
    myPeerConnection.onnegotiationneeded = null;

    if (remoteVideo.srcObject) {
      remoteVideo.srcObject.getTracks().forEach((track) => track.stop());
    }

    if (localVideo.srcObject) {
      localVideo.srcObject.getTracks().forEach((track) => track.stop());
    }

    myPeerConnection.close();
    myPeerConnection = null;
  }

  remoteVideo.removeAttribute("src");
  remoteVideo.removeAttribute("srcObject");
  localVideo.removeAttribute("src");
  localVideo.removeAttribute("srcObject");

  document.getElementById("hangup-button").disabled = true;
  targetUsername = null;
}

在提取对两个 <video> 元素的引用后,我们将检查是否存在 WebRTC 连接;如果存在,我们将继续断开连接并关闭通话

  1. 所有事件处理程序都将被删除。这将防止在连接关闭过程中触发杂散事件处理程序,从而可能导致错误。
  2. 对于远程视频流和本地视频流,我们都会遍历每个轨道,调用 MediaStreamTrack.stop() 方法来关闭每个轨道。
  3. 通过调用 myPeerConnection.close() 来关闭 RTCPeerConnection
  4. myPeerConnection 设置为 null,确保我们的代码知道没有正在进行的通话;这在用户在用户列表中点击姓名时很有用。

然后,对于传入的和传出的 <video> 元素,我们使用它们的 removeAttribute() 方法删除它们的 srcsrcObject 属性。这完成了流与视频元素的解除关联。

最后,我们将“挂断”按钮的 disabled 属性设置为 true,使其在没有通话进行时无法点击;然后我们将 targetUsername 设置为 null,因为我们不再与任何人通话。这允许用户呼叫另一个用户,或接收来电。

处理状态更改

您可以设置监听器来通知您的代码各种状态更改,还有许多其他事件。我们使用其中三个:iceconnectionstatechangeicegatheringstatechangesignalingstatechange

ICE 连接状态

iceconnectionstatechange 事件由 ICE 层在连接状态发生变化时(例如,当通话从另一端终止时)发送到 RTCPeerConnection

js
function handleICEConnectionStateChangeEvent(event) {
  switch (myPeerConnection.iceConnectionState) {
    case "closed":
    case "failed":
      closeVideoCall();
      break;
  }
}

在这里,当 ICE 连接状态更改为 "closed""failed" 时,我们将应用我们的 closeVideoCall() 函数。这将处理关闭我们这端的连接,以便我们再次准备好开始或接受通话。

注意: 我们在这里不观察 disconnected 信令状态,因为它可能表示临时问题,并且可能在一段时间后恢复到 connected 状态。观察它将关闭任何临时网络问题上的视频通话。

ICE 信令状态

同样,我们也会监听 signalingstatechange 事件。如果信令状态更改为 closed,我们也会关闭通话。

js
function handleSignalingStateChangeEvent(event) {
  switch (myPeerConnection.signalingState) {
    case "closed":
      closeVideoCall();
      break;
  }
}

注意: closed 信令状态已被弃用,取而代之的是 closed iceConnectionState。我们在这里观察它是为了增加一些向后兼容性。

ICE 收集状态

icegatheringstatechange 事件用于通知您 ICE 候选人收集过程状态的变化。我们的示例没有使用它,但是监视这些事件对于调试很有用,也可以用于检测候选人收集何时完成。

js
function handleICEGatheringStateChangeEvent(event) {
  // Our sample just logs information to console here,
  // but you can do whatever you need.
}

后续步骤

您现在可以 在 Glitch 上尝试此示例 以查看其工作方式。在两个设备上打开 Web 控制台并查看记录的输出 - 虽然您在上面显示的代码中看不到它,但服务器上的代码(以及 GitHub 上的代码)有很多控制台输出,以便您可以看到信令过程和连接过程的运作方式。

另一个明显的改进是添加一个“响铃”功能,这样,在向用户询问使用摄像头和麦克风的权限之前,会先出现一个“用户 X 正在呼叫。您要接听吗?”提示。

另请参阅