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 节中已经介绍了如何通过 WebRTCgetUserMedia() 接口采集音视频数据。数据采集到了,我们该如何将采集到的数据发送给对方呢?想必你一定猜到了,使用 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 管理。pcRTCPeerConnection 的缩写,也是一个全局变量。当 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} 多一些,它还包括 CandidateTypeufrag 等。代码 5.12 是一个真实的 Candidate 所包含的信息。

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

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

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

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

收集Candidate

WebRTC 收集 Candidate 时有几种途径:host 类型的 Candidate,是根据主机的网卡个数来决定的,一般来说,一个网卡对应一个 IP 地址,给每个 IP 地址随机分配一个端口从而生成一个 host 类型的 Candidatesrflx 类型的 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(即连接候选者对)。有了 CandidatePairWebRTC 就可以开始尝试建立连接了。这里需要注意的是,Candidate 的交换不是等所有 Candidate 收集好后才进行的,而是边收集边交换。

尝试连接

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

SDP与Candidate消息的交换

在 5.7.3 节和 5.7.4 节中都提到了通信双方要进行信息的交换,如交换 SDPCandidate。这种信息交换使用的也是之前介绍的信令系统,只不过需要为这种需求专门设置一个新的信令,即 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.ioemit() 方法就可以将消息发送给服务器;服务端收到消息后,调用 socket.to(room).emit() 方法给房间里除自己之外的所有人转发消息。因为我们在逻辑层控制了一个房间内只能有两个人,所以实际上服务端只会给另一方转发消息;在接收端,它会为 message 消息注册一个回调函数,当收到 message 消息后,函数被回调。在回调函数内部对消息的类型又做了判断,对于不同的消息类型,如 offeranswercandidate,做不同的逻辑处理。以上就是消息交换的整个过程。

远端音视频渲染

当各端将收集到的 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 对象,然后为 pcontrack() 事件设置了一个回调函数,即 getRemoteStream() 。该回调函数有一个输入参数 e,其中就包括了远端的 MediaStream。在回调函数中,需要将获得的 MediaStream 对象赋值给 <video> 标签,这样远端的音视频数据就与 <video> 标签绑定好了。

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

客户端完整例子

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