RTCPeerConnection

RTCPeerConnection 对象是 WebRTC 的核心,它是 WebRTC 暴露给用户的统一接口,其内部由多个模块组成,如网络处理模块、服务质量模块、音视频引擎模块,等等。你可以把它想象成一个超级 socket,通过它可以轻松地完成端到端数据的传输。更让人惊讶的是,它还可以根据实际网络情况动态调整出最佳的服务质量。

接下来我们将用 6 个小节详细讲述如何通过 RTCPeerConnection 完成一对一实时通信。

创建RTCPeerConnection对象

首先,我们来看一下如何在浏览器上创建一个 RTCPeerConnection 对象,其原型如代码 5.10 所示。

代码5.10 创建RTCPeerConnection
const configuration = {
  iceServers: [
    { urls: 'stun:stun.example.org' }
  ]
};

// ...
let pc = new RTCPeerConnection(configuration);
// ...

创建 RTCPeerConnection 对象很简单,只要通过 new 关键字就可以将 RTCPeerConnection 对象创建出来。在创建 RTCPeerConnection 对象时,需要给它传输一个参数。这个参数是一个 JSON 格式的数据,通过该参数可以对 WebRTC 中数据传输方式做一些策略选择。比如,通信时是以中继方式传输数据,还是使用 P2P 方式传输数据?如果使用中继方式,那它可以使用哪些中断服务器?这些问题都可以通过创建 RTCPeerConnection 对象的输入参数来设定。

关于 RTCPeerConnection 对象输入参数的具体内容,将在第 6 章中再做进一步讲解。

RTCPeerConnection与本地音视频数据绑定

在 5.3 节中已经介绍了如何通过 WebRTC 的 getUserMedia() 接口采集音视频数据。数据采集到了,我们该如何将采集到的数据发送给对方呢?想必你一定猜到了,使用 RTCPeerConnection。

不过,在使用 RTCPeerConnection 对象将数据发送给对方之前,还需要解决一个关键问题,即如何将采集到的数据与 RTCPeerConnection 对象绑定到一起。只有让 RTCPeerConnection 拿到音视频数据,它才能将其发送出去。

对于绑定数据的问题,RTCPeerConnection 对象为我们提供了两种方法:一个是 addTrack() ;另一个是 addStream() 。这两种方法都可以实现将采集到的数据与 RTCPeerConnection 绑定的作用,不过由于 WebRTC 规范中已经将 addStream() 标记为过时,因此建议尽量使用 addTrack() 方法,以免以后出现兼容性问题。

正如在 5.6 节中介绍的,当客户端从服务端接收到 joined 消息后,它会创建 RTCPeerConnection 对象,然后调用 bindTracks() 函数将其与之前通过getUserMedia() 接口采集到的音视频数据绑定到一起,如代码 5.11 所示。

代码5.11 绑定Track
// ...
function bindTracks() {
  // ...
  ls.getTracks().forEach((track) => {
    pc.addTrack(track, ls);
    // ...
  });
}
// ...

在上面的代码中,ls 是一个全局变量,当通过 getUserMedia() 接口采集到 MediaStream 后,需要将其交由 ls 管理。pc 是 RTCPeerConnection 的缩写,也是一个全局变量。当 RTCPeerConnection 创建好后,交由 pc 管理。这样当调用 bindTracks() 函数时,它就可以从 ls 中获取每一个准备好的 track,然后将其加入 RTCPeerConnection 对象中,从而实现了音视频数据与 RTCPeerConnection 对象绑定的工作。

媒体协商

当 RTCPeerConnection 对象与音视频绑定后,紧接着需要进行媒体协商。什么是媒体协商呢?其实,它就像我们买卖东西时的讨价还价。通信的双方在真正通信之前,也要讨价还价,以了解彼此都有哪些能力。比如说,你默认使用的编码器是 VP8,要想与对方通信,还需要知道对方是否可以解码 VP8 的数据。如果对方不支持 VP8 解码,那你就不能使用这个编码器。再比如,通信中的一方说,我的数据是使用 DTLS-SRTP 加密的,而另一方也必须具备这种能力,否则双方无法通信。这就是媒体协商。

进行媒体协商时,交换的内容是 SDP 格式的。关于媒体协商内容方面的知识,将在第 7 章中详细讨论,这里我们只关注媒体协商的过程即可。

在 WebRTC 中,媒体协商是有严格的协商顺序的,其过程如图 5.2 所示,整个协商过程共 8 步。下面详述一下这个过程。

image 2025 02 23 10 13 16 114
Figure 1. 图5.2 媒体协商

这里我们假设协商的发起方是用户 A,当它创建好 RTCPeerConnection 对象并与采集到的数据绑定后,开始执行图 5.2 中的第❶步,即调用 RTCPeerConnection 对象的 createOffer 接口生成 SDP 格式的本地协商信息 Offer;本地协商信息 Offer 生成后,再调用 setLocalDescription 接口,将 Offer 保存起来(图中的第❷步);之后通过客户端的信令系统将 Offer 信息发送给远端用户B(图中第❸步)。此时用户 A 的媒体协商过程暂告一段落(还未完成)。

用户 B 通过信令系统收到用户 A 的 Offer 信息后,调用本地 RTCPeerConnection 对象的 setRemoteDescription 接口,将 Offer 信息保存起来(图中的第❹步);这一步完成后,再调用 createAnswer 接口创建 Answer 消息(图中的第❺步)(Answer 消息也是 SDP 格式,里边记录的是用户 B 端的协商信息);Answer 消息创建好后,用户 B 调用 setLocalDescription 接口将 Answer 信息保存起来(图中的第❻步)。至此,用户 B 端的媒体协商已经完成。接下来,用户 B 需要将 Answer 消息发送给 A 端(图中的第❼步),以便让用户 A 继续完成自己的媒体协商。

用户 A 收到用户 B 的 Answer 消息后,就可以重启其未完成的媒体协商了。用户 A 需要调用 RTCPeerConnection 对象的 setRemoteDescription 接口将收到的 Answer 消息保存起来(图中第❽步)。执行完这一步后,整个媒体协商过程才算最终完成。

ICE

当媒体协商完成后,WebRTC 就开始建立网络连接了,其过程称为 ICE。更确切地说,ICE 是在各端调用 setLocalDescription() 接口后就开始了。其操作过程如下:收集 Candidate,交换 Candidate,按优先级尝试连接。

什么是Candidate

在介绍如何收集 Candidate 之前,我们先了解一下什么是 Candidate。举个例子,比如我们想用 socket 连接某台服务器,一定要知道这台服务器的一些基本信息,如服务器的 IP 地址、端口号以及使用的传输协议。只有知道了这些信息,才能与这台服务器建立连接。而 Candidate 正是 WebRTC 用来描述它可以连接的远端的基本信息,因此它是至少包括 {address,port,protocol} 三元组的一个信息集。

当然,真正的 Candidate 包含的内容要比三元组 {address,port,protocol} 多一些,它还包括 CandidateType、ufrag 等。代码 5.12 是一个真实的 Candidate 所包含的信息。

代码5.12 Candidate信息
{
  "candidate": "udp 192.168.1.9 45845 type host … ufrag aOj8 …",
  "sdpMid": "0",
  "sdpMLineIndex": 0
}

通过上面的信息可以看到,IceCandidate 的结构由 candidate、sdpMid 和 sdpMLineIndex 三部分组成。其中最关键的内容放在 candidate 字段中,也就是第 3 行代码里的内容(这行内容已经做了删减,将一些无关紧要的内容删掉了)。

从第 3 行代码中还可以知道,它包括了该 IceCandidate 使用的传输协议(UDP)、IP地址、端口号、Candidate 类型(type host)以及用户名(ufrag a0j8)。有了这条信息,WebRTC 就可以尝试与远端进行连接了。需要注意的是,实际中使用的 IceCandidate 结构与 WebRTC 1.0 规范中定义的 IceCandidate 结构有很大出入。之所以会出现这种情况,主要是因为 WebRTC 1.0 规范出来得较晚,各浏览器厂商还是按之前的草案来实现的。不过相信未来各浏览器厂商最终还是会按 WebRTC 规范来实现的。

WebRTC 将 Candidate 分成了四种类型,即 host、srflx、prflx 及 relay,且它们还有优先级次序,其中 host 优先级最高,relay 优先级最低。比如 WebRTC 收集到了两个 Candidate,一个是 host 类型,另一个是 srflx 类型,那么 WebRTC 一定会先尝试与 host 类型的 Candidate 建立连接,如果不成功,才会使用 srflx 类型的 Candidate。

收集Candidate

WebRTC 收集 Candidate 时有几种途径:host 类型的 Candidate,是根据主机的网卡个数来决定的,一般来说,一个网卡对应一个 IP 地址,给每个 IP 地址随机分配一个端口从而生成一个 host 类型的 Candidate;srflx 类型的 Candidate,是从 STUN 服务器获得的 IP 地址和端口生成的;relay 类型的 Candidate,是通过 TRUN 服务器获得的 IP 地址和端口号生成的。

收集到 Candidate 后,为了通知上层,WebRTC 还在 RTCPeerConnection 对象中提供了一个事件,即 onicecandidate。为了将收集到的 Candidate 交换给对端,需要为 onicecandidate 事件设置一个回调函数。如代码 5.13 所示。

代码5.13 获取本地Candidate
pc.onicecandidate = (e) => {
  if (e.candidate) {
    // ...
  }
};

通过该回调函数就可以获得 WebRTC 底层收集到的所有 Candidate 了。同时,还可以在该函数中将收集到的 Candidate 发送给对端。

交换Candidate

WebRTC 收集好 Candidate 后,会通过信令系统将它们发送给对端。对端接收到这些 Candidate 后,会与本地的 Candidate 形成 CandidatePair(即连接候选者对)。有了 CandidatePair,WebRTC 就可以开始尝试建立连接了。这里需要注意的是,Candidate 的交换不是等所有 Candidate 收集好后才进行的,而是边收集边交换。

尝试连接

当 WebRTC 形成 CandidatePair 后,便开始尝试进行连接。一旦 WebRTC 发现其中有一个可以连通的 CandidatePair 时,它就不再进行后面的连接尝试了,但发现新的 Candidate 时仍然可以继续进行交换。

SDP与Candidate消息的交换

在 5.7.3 节和 5.7.4 节中都提到了通信双方要进行信息的交换,如交换 SDP 和 Candidate。这种信息交换使用的也是之前介绍的信令系统,只不过需要为这种需求专门设置一个新的信令,即 message。

下面我们看一下信息交换的过程。其过程非常简单,当通信双方需要交换信息时,发起方首先向信令服务器发送 message 消息,服务端收到 message 消息后不做任何处理,直接将该消息转发给目标用户。

根据上述消息交换的过程,我们知道消息交换分成三个步骤,即发起方发送要交换的消息,服务端收到消息后进行转发,客户端接收消息。具体实现如下:

  • 客户端发送消息,参见代码 5.14。

    代码5.14 客户端发送消息
    function sendMessage(roomid, data) {
      // ...
      socket.emit('message', roomid, data);
    }
  • 服务端收到消息后转发,参见代码 5.15。

    代码5.15 服务端收到消息后转发
    socket.on('message', (room, data) => {
      // ...
      socket.to(room).emit('message', room, data);
    });
  • 客户端接收消息,参见代码 5.16。

代码5.16 客户端接收消息
socket.on('message', (roomid, data) => {
  // ...
  if (data.hasOwnProperty('type') && data.type === 'offer') {
    // ...
  } else if (data.hasOwnProperty('type') && data.type === 'answer') {
    // ...
  } else if (data.hasOwnProperty('type') && data.type === 'candidate') {
    // ...
  } else {
    // ...
  }
});

通过上面的代码可以看到,交换消息的处理还是很简单的。对于发送方来说,只需要调用 socket.io 的 emit() 方法就可以将消息发送给服务器;服务端收到消息后,调用 socket.to(room).emit() 方法给房间里除自己之外的所有人转发消息。因为我们在逻辑层控制了一个房间内只能有两个人,所以实际上服务端只会给另一方转发消息;在接收端,它会为 message 消息注册一个回调函数,当收到 message 消息后,函数被回调。在回调函数内部对消息的类型又做了判断,对于不同的消息类型,如 offer、answer、candidate,做不同的逻辑处理。以上就是消息交换的整个过程。

远端音视频渲染

当各端将收集到的 Candidate 通过信令系统交换给对方后,WebRTC 内部就开始尝试建立连接了。连接一旦建成,音视频数据就开始源源不断地由发送端发送给接收端。

不过,此刻音视频数据即使到达了接收端,我们也看不见、听不到它,这是因为 WebRTC 还不知道如何处理收到的音视频数据。如何做才能让收到的视频数据在屏幕上显示,音频数据在扬声器里播放呢?

事实上,在播放音视频数据之前,我们需要将远端传来的音视频数据流与本地 <video> 标签绑定才行。具体的做法是将收到的音视频数据流(MediaStream)赋值给 <video> 标签的 srcObject 属性。接下来的问题就是如何才能获得远端的音视频流(MediaStream)?

在这方面,WebRTC 给我们提供了一个非常好的接口,即 RTCPeerConnection 对象的 ontrack() 事件。每当有远端的音视频数据传过来时,ontrack() 事件就会被触发。因此你只需要给 ontrack() 事件设置一个回调函数,就可以拿到远端的 MediaStream 了。具体代码参见代码 5.17。

代码5.17 获取远端视频流
function getRemoteStream(e) {
  // ...
}

let pc = new RTCPeerConnection(/* configuration */);
// ...
pc.ontrack = getRemoteStream;
// ...

在上述代码中,首先创建了 RTCPeerConnection 对象,然后为 pc 的 ontrack() 事件设置了一个回调函数,即 getRemoteStream() 。该回调函数有一个输入参数 e,其中就包括了远端的 MediaStream。在回调函数中,需要将获得的 MediaStream 对象赋值给 <video> 标签,这样远端的音视频数据就与 <video> 标签绑定好了。

在上述代码中,首先创建了 RTCPeerConnection 对象,然后为 pc 的 ontrack() 事件设置了一个回调函数,即 getRemoteStream()。该回调函数有一个输入参数 e,其中就包括了远端的 MediaStream。在回调函数中,需要将获得的 MediaStream 对象赋值给 <video> 标签,这样远端的音视频数据就与 <video> 标签绑定好了。

客户端完整例子

为了方便能更快速地将 WebRTC 一对一通信的客户端搭建起来,依然将客户端的完整代码放在本章的最后以供参考。需要说明的是,客户端的实现相对于服务端来说要复杂得多,它由三个文件组成,分别是 room.html、client.css 以及 client.js。

room.html 文件用于界面的展示,它由三个展示区组成,即用户操作区、视频直播展示区和 SDP 展示区。其中,用户操作区用于连接信令服务器或断开与信令服务器的连接;视频直播展示区由两个 <video> 标签组成,一个用于本地视频预览,另一个用于显示远端视频;SDP 展示区用于查看和分析媒体协商中的 Offer/Answer 内容。其界面如图 5.3 所示。

image 2025 02 23 10 56 24 283
Figure 2. 图5.3 WebRTC一对一通信客户端界面

client.css 文件用于美化 room.html 界面。由于该文件中的内容与本书所讲的内容并无太大关系,所以这里只是将其列出,有兴趣的读者可以自行对其进行研究。对于该文件唯一要说明的是,在使用本例程时,需要将它存放在 room.html 所在目录的 css 子目录下,这样才能让它正常工作。

client.js 文件是本例程最重要的文件,里面包含了实现 WebRTC 一对一通信最核心的逻辑。因此,我们对该文件中的重要函数和关键的语句都做了详细注释。相信通过本章前面讲解的内容,再加上代码中的注释,读者在阅读该文件的代码时不会感到有太大的困难。该文件应该存放在 room.html 目录的 js 子目录下。

首先看一下 room.html 是如何实现的,如代码 5.18 所示。

代码5.18 客户端HTML代码
<html>
  <head>
    <title>WebRTC PeerConnection</title>
    <link href="./css/client.css" rel="stylesheet" />
  </head>
  <body>
    <div>
      <!--
        用户操作区,包括两个 Button:
        一个用户连接信令服务器;另一个用户与信令服务器断开连接
      -->
      <div>
        <button id="connserver">ConnServer</button>
        <button id="leave" disabled>Leave</button>
      </div>

      <!--
        显示区,用于展示:
        ·视频
        ·Offer/Answer
      -->
      <div id="preview">
        <!--
          本地视频与 Offer 的展示区
        -->
        <div>
          <h2>Local:</h2>
          <video id="localvideo" autoplay playsinline muted></video>
          <h2>Offer SDP:</h2>
          <textarea id="offer"></textarea>
        </div>

        <!--
          远端视频与 Answer 的展示区
        -->
        <div>
          <h2>Remote:</h2>
          <video id="remotevideo" autoplay playsinline></video>
          <h2>Answer SDP:</h2>
          <textarea id="answer"></textarea>
        </div>
      </div>
    </div>

    <!--
      引用的 JavaScript 脚本库:
      socket.io.js:用于连接信令服务器
      adapter-latest.js:用于浏览器适配 Chrome, Firefox …
      main.js:WebRTC 客户端代码
    -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
    <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    <script src="js/main.js"></script>
  </body>
</html>

接下来是 client.css 的实现,由于该文件比较简单,且与我们所讲内容关系不大,所以我们并没有对其进行注释,可以直接复制使用。其实现如代码 5.19 所示。

代码5.19 客户端引用的CSS代码
button {
  margin: 10px 20px 25px 0;
  vertical-align: top;
  width: 134px;
}

table {
  margin: 200px calc(50% - 100px) 0 0;
}

textarea {
  color: #444;
  font-size: 0.9em;
  font-weight: 300;
  height: 20em;
  padding: 5px;
  width: calc(100% - 10px);
}

div#getUserMedia {
  padding: 0 0 8px 0;
}

div.input {
  display: inline-block;
  margin: 0 4px 0 0;
  vertical-align: top;
  width: 310px;
}

div.input > div {
  margin: 0 0 20px 0;
  vertical-align: top;
}

div.output {
  background-color: #eee;
  display: inline-block;
  font-family: 'Inconsolata', 'Courier New', monospace;
  font-size: 0.9em;
  padding: 10px 10px 10px 25px;
  position: relative;
  top: 10px;
  white-space: pre;
  width: 270px;
}

div.label {
  display: inline-block;
  font-weight: 400;
  width: 120px;
}

div.graph-container {
  background-color: #ccc;
  float: left;
  margin: 0.5em;
  width: calc(50% - 1em);
}

div#preview {
  border-bottom: 1px solid #eee;
  margin: 0 0 1em 0;
  padding: 0 0 0.5em 0;
}

div#preview > div {
  display: inline-block;
  vertical-align: top;
  width: calc(50% - 12px);
}

section#statistics div {
  display: inline-block;
  font-family: 'Inconsolata', 'Courier New', monospace;
  vertical-align: top;
  width: 308px;
}

section#statistics div#senderStats {
  margin: 0 20px 0 0;
}

section#constraints > div {
  margin: 0 0 20px 0;
}

h2 {
  margin: 0 0 1em 0;
}

section#constraints label {
  display: inline-block;
  width: 156px;
}

section {
  margin: 0 0 20px 0;
  padding: 0 0 15px 0;
}

video {
  background: #222;
  margin: 0;
  --width: 100%;
  width: var(--width);
  height: 225px;
}

@media screen and (max-width: 720px) {
  button {
    font-weight: 500;
    height: 56px;
    line-height: 1.3em;
    width: 90px;
  }

  div#getUserMedia {
    padding: 0 0 40px 0;
  }

  section#statistics div {
    width: calc(50% - 14px);
  }
}

最后是 client.js 文件的实现代码,该文件最重要。由于该文件比较大,且有一定难度,所以需要仔细阅读,参见代码 5.20。

代码5.20 客户端JS代码
'use strict';

// 本地视频预览窗口
var localVideo = document.querySelector('video#localvideo');

// 远端视频预览窗口
var remoteVideo = document.querySelector('video#remotevideo');

// 连接信令服务器 Button
var btnConn = document.querySelector('button#connserver');

// 与信令服务器断开连接 Button
var btnLeave = document.querySelector('button#leave');

// 查看 Offer 文本窗口
var offer = document.querySelector('textarea#offer');

// 查看 Answer 文本窗口
var answer = document.querySelector('textarea#answer');

// PeerConnection 配置
var pcConfig = {
  iceServers: [
    // TURN 服务器地址
    {
      urls: 'turn:xxx.avdancedu.com:3478',
      username: 'xxx', // TURN 服务器用户名
      credential: 'xxx' // TURN 服务器密码
    }
  ],
  // 默认使用 relay 方式传输数据
  iceTransportPolicy: 'relay',
  iceCandidatePoolSize: '0'
};

// 本地视频流
var localStream = null;

// 远端视频流
var remoteStream = null;

// PeerConnection
var pc = null;

// 房间号
var roomid;

// socket.io 连接
var socket = null;

// offer 描述
var offerdesc = null;

// 状态机,初始为 init
var state = 'init';

/**
 * 功能:判断此浏览器是在 PC 端,还是移动端。
 * 返回值:false,说明当前操作系统是移动端;true,说明当前的操作系统是 PC 端。
 */
function IsPC() {
  var userAgentInfo = navigator.userAgent;
  var Agents = ['Android', 'iPhone', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod'];
  var flag = true;

  for (var v = 0; v < Agents.length; v++) {
    if (userAgentInfo.indexOf(Agents[v]) > 0) {
      flag = false;
      break;
    }
  }

  return flag;
}

/**
 * 功能:判断是 Android 端还是 iOS 端。
 * 返回值:true,说明是 Android 端;false,说明是 iOS 端。
 */
function IsAndroid() {
  var u = navigator.userAgent,
    app = navigator.appVersion;
  var isAndroid = u.indexOf('Android') > -1 || u.indexOf('Linux') > -1;
  var isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);

  if (isAndroid) {
    return true;
  }

  if (isIOS) {
    return false;
  }
}

/**
 * 功能:从 url 中获取指定的域值。
 * 返回值:指定的域值或 false。
 */
function getQueryVariable(variable) {
  var query = window.location.search.substring(1);
  var vars = query.split('&');
  for (var i = 0; i < vars.length; i++) {
    var pair = vars[i].split('=');
    if (pair == variable) {
      return pair;
    }
  }
  return false;
}

/**
 * 功能:向对端发消息。
 * 返回值:无。
 */
function sendMessage(roomid, data) {
  console.log('send message to other end', roomid, data);
  if (!socket) {
    console.log('socket is null');
  }
  socket.emit('message', roomid, data);
}

/**
 * 功能:与信令服务器建立 socket.io 连接,并根据信令更新状态机。
 * 返回值:无。
 */
function conn() {
  // 连接信令服务器
  socket = io.connect();

  // 'joined' 消息处理函数
  socket.on('joined', (roomid, id) => {
    console.log('receive joined message!', roomid, id);

    // 状态机变更为 'joined'
    state = 'joined';

    // 如果是 Mesh 方案,第一个人不该在这里创建 peerConnection,而是要等到所有端都收到 'otherjoin' 消息时再创建

    // 创建 PeerConnection 并绑定音视频轨
    createPeerConnection();
    bindTracks();

    // 设置 button 状态
    btnConn.disabled = true;
    btnLeave.disabled = false;

    console.log('receive joined message, state=', state);
  });

  // 'otherjoin' 消息处理函数
  socket.on('otherjoin', (roomid) => {
    console.log('receive joined message:', roomid, state);

    // 如果是多人,每加入一个人都要创建一个新的 PeerConnection
    if (state === 'joined_unbind') {
      createPeerConnection();
      bindTracks();
    }

    // 状态机变更为 joined_conn
    state = 'joined_conn';

    // 开始“呼叫”对方
    call();

    console.log('receive other_join message, state=', state);
  });

  // 'full' 消息处理函数
  socket.on('full', (roomid, id) => {
    console.log('receive full message', roomid, id);

    // 关闭 socket.io 连接
    socket.disconnect();

    // 挂断“呼叫”
    hangup();

    // 关闭本地媒体
    closeLocalMedia();

    // 状态机变更为 leaved
    state = 'leaved';

    console.log('receive full message, state=', state);
    alert('the room is full!');
  });

  // 'leaved' 消息处理函数
  socket.on('leaved', (roomid, id) => {
    console.log('receive leaved message', roomid, id);

    // 状态机变更为 leaved
    state = 'leaved';

    // 关闭 socket.io 连接
    socket.disconnect();

    console.log('receive leaved message, state=', state);

    // 改变 button 状态
    btnConn.disabled = false;
    btnLeave.disabled = true;
  });

  // 'bye' 消息处理函数
  socket.on('bye', (room, id) => {
    console.log('receive bye message', roomid, id);

    // 状态机变更为 joined_unbind
    state = 'joined_unbind';

    // 挂断“呼叫”
    hangup();

    offer.value = '';
    answer.value = '';

    console.log('receive bye message, state=', state);
  });

  // socket.io 连接断开处理函数
  socket.on('disconnect', (socket) => {
    console.log('receive disconnect message!', roomid);
    if (!(state === 'leaved')) {
      // 挂断“呼叫”
      hangup();

      // 关闭本地媒体
      closeLocalMedia();
    }

    // 状态机变更为 leaved
    state = 'leaved';
  });

  // 收到对端消息处理函数
  socket.on('message', (roomid, data) => {
    console.log('receive message!', roomid, data);

    if (data === null || data === undefined) {
      console.error('the message is invalid!');
      return;
    }

    // 如果收到的 SDP 是 offer
    if (data.hasOwnProperty('type') && data.type === 'offer') {
      offer.value = data.sdp;

      // 进行媒体协商
      pc.setRemoteDescription(new RTCSessionDescription(data));

      // 创建 answer
      pc.createAnswer()
        .then(getAnswer)
        .catch(handleAnswerError);

      // 如果收到的 SDP 是 answer
    } else if (data.hasOwnProperty('type') && data.type === 'answer') {
      answer.value = data.sdp;

      // 进行媒体协商
      pc.setRemoteDescription(new RTCSessionDescription(data));

      // 如果收到的是 Candidate 消息
    } else if (data.hasOwnProperty('type') && data.type === 'candidate') {
      var candidate = new RTCIceCandidate({
        sdpMLineIndex: data.label,
        candidate: data.candidate
      });

      // 将远端 Candidate 消息添加到 PeerConnection 中
      pc.addIceCandidate(candidate);
    } else {
      console.log('the message is invalid!', data);
    }
  });

  // 从 url 中获取 roomid
  roomid = getQueryVariable('room');

  // 发送 'join' 消息
  socket.emit('join', roomid);

  return true;
}

/**
 * 功能:打开音视频设备,并连接信令服务器。
 * 返回值:永远为 true。
 */
function connSignalServer() {
  // 开启本地视频
  start();

  return true;
}

/**
 * 功能:打开音视频设备成功时的回调函数。
 * 返回值:永远为 true。
 */
function getMediaStream(stream) {
  // 将从设备上获取到的音视频 track 添加到 localStream 中
  if (localStream) {
    stream.getAudioTracks().forEach((track) => {
      localStream.addTrack(track);
      stream.removeTrack(track);
    });
  } else {
    localStream = stream;
  }

  // 本地视频标签与本地流绑定
  localVideo.srcObject = localStream;

  /* 调用 conn() 函数的位置特别重要,一定要在 getMediaStream 调用之后再调用它,否则就会出现绑定失败的情况 */

  // setup connection
  conn();
}

/**
 * 功能:错误处理函数。
 * 返回值:无。
 */
function handleError(err) {
  console.error('Failed to get Media Stream!', err);
}

/**
 * 功能:打开音视频设备。
 * 返回值:无。
 */
function start() {
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    console.error('the getUserMedia is not supported!');
    return;
  } else {
    var constraints;

    constraints = {
      video: true,
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
      }
    };

    navigator.mediaDevices.getUserMedia(constraints)
      .then(getMediaStream)
      .catch(handleError);
  }
}

/**
 * 功能:获得远端媒体流。
 * 返回值:无。
 */
function getRemoteStream(e) {
  // 存放远端视频流
  remoteStream = e.streams;

  // 远端视频标签与远端视频流绑定
  remoteVideo.srcObject = e.streams;
}

/**
 * 功能:处理 Offer 错误。
 * 返回值:无。
 */
function handleOfferError(err) {
  console.error('Failed to create offer:', err);
}

/**
 * 功能:处理 Answer 错误。
 * 返回值:无。
 */
function handleAnswerError(err) {
  console.error('Failed to create answer:', err);
}

/**
 * 功能:获取 Answer SDP 描述符的回调函数。
 * 返回值:无。
 */
function getAnswer(desc) {
  // 设置 Answer
  pc.setLocalDescription(desc);

  // 将 Answer 显示出来
  answer.value = desc.sdp;

  // 将 Answer SDP 发送给对端
  sendMessage(roomid, desc);
}

/**
 * 功能:获取 Offer SDP 描述符的回调函数。
 * 返回值:无。
 */
function getOffer(desc) {
  // 设置 Offer
  pc.setLocalDescription(desc);

  // 将 Offer 显示出来
  offer.value = desc.sdp;
  offerdesc = desc;

  // 将 Offer SDP 发送给对端
  sendMessage(roomid, offerdesc);
}

/**
 * 功能:创建 PeerConnection 对象。
 * 返回值:无。
 */
function createPeerConnection() {
  /*
   * 如果是多人的话,在这里要创建一个新的连接。新创建好的要放到一个映射表中
   * key=userid, value=peerconnection
   */
  console.log('create RTCPeerConnection!');
  if (!pc) {
    // 创建 PeerConnection 对象
    pc = new RTCPeerConnection(pcConfig);

    // 当收集到 Candidate 后
    pc.onicecandidate = (e) => {
      if (e.candidate) {
        console.log('candidate' + JSON.stringify(e.candidate.toJSON()));

        // 将 Candidate 发送给对端
        sendMessage(roomid, {
          type: 'candidate',
          label: event.candidate.sdpMLineIndex,
          id: event.candidate.sdpMid,
          candidate: event.candidate.candidate
        });
      } else {
        console.log('this is the end candidate');
      }
    };

    /*
     * 当 PeerConnection 对象收到远端音视频流时,触发 ontrack 事件,并回调 getRemoteStream 函数
     */
    pc.ontrack = getRemoteStream;
  } else {
    console.log('the pc have be created!');
  }

  return;
}

/**
 * 功能:将音视频 track 绑定到 PeerConnection 对象中。
 * 返回值:无。
 */
function bindTracks() {
  console.log('bind tracks into RTCPeerConnection!');

  if (pc === null && localStream === undefined) {
    console.error('pc is null or undefined!');
    return;
  }

  if (localStream === null && localStream === undefined) {
    console.error('localstream is null or undefined!');
    return;
  }

  // 将本地音视频流中所有的 track 添加到 PeerConnection 对象中
  localStream.getTracks().forEach((track) => {
    pc.addTrack(track, localStream);
  });
}

/**
 * 功能:开启“呼叫”。
 * 返回值:无。
 */
function call() {
  if (state === 'joined_conn') {
    var offerOptions = {
      offerToReceiveAudio: 1,
      offerToReceiveVideo: 1
    };

    /*
     * 创建 Offer,
     * 如果成功,则回调 getOffer() 方法
     * 如果失败,则回调 handleOfferError() 方法
     */
    pc.createOffer(offerOptions)
      .then(getOffer)
      .catch(handleOfferError);
  }
}

/**
 * 功能:挂断“呼叫”。
 * 返回值:无。
 */
function hangup() {
  if (!pc) {
    return;
  }

  offerdesc = null;

  // 将 PeerConnection 连接关掉
  pc.close();
  pc = null;
}

/**
 * 功能:关闭本地媒体。
 * 返回值:无。
 */
function closeLocalMedia() {
  if (!(localStream === null || localStream === undefined)) {
    // 遍历每个 track,并将其关闭
    localStream.getTracks().forEach((track) => {
      track.stop();
    });
  }
  localStream = null;
}

/**
 * 功能:离开房间。
 * 返回值:无。
 */
function leave() {
  // 向信令服务器发送 leave 消息
  socket.emit('leave', roomid);

  // 挂断“呼叫”
  hangup();

  // 关闭本地媒体
  closeLocalMedia();

  offer.value = '';
  answer.value = '';

  btnConn.disabled = false;
  btnLeave.disabled = true;
}

// 为 Button 设置单击事件
btnConn.onclick = connSignalServer;
btnLeave.onclick = leave;