RTCPeerConnection
RTCPeerConnection
对象是 WebRTC
的核心,它是 WebRTC
暴露给用户的统一接口,其内部由多个模块组成,如网络处理模块、服务质量模块、音视频引擎模块,等等。你可以把它想象成一个超级 socket
,通过它可以轻松地完成端到端数据的传输。更让人惊讶的是,它还可以根据实际网络情况动态调整出最佳的服务质量。
接下来我们将用 6 个小节详细讲述如何通过 RTCPeerConnection
完成一对一实时通信。
创建RTCPeerConnection对象
首先,我们来看一下如何在浏览器上创建一个 RTCPeerConnection
对象,其原型如代码 5.10 所示。
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 所示。
// ...
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 步。下面详述一下这个过程。

这里我们假设协商的发起方是用户 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
所包含的信息。
{
"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 所示。
pc.onicecandidate = (e) => {
if (e.candidate) {
// ...
}
};
通过该回调函数就可以获得 WebRTC
底层收集到的所有 Candidate
了。同时,还可以在该函数中将收集到的 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。
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。
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 所示。

client.css
文件用于美化 room.html
界面。由于该文件中的内容与本书所讲的内容并无太大关系,所以这里只是将其列出,有兴趣的读者可以自行对其进行研究。对于该文件唯一要说明的是,在使用本例程时,需要将它存放在 room.html
所在目录的 css
子目录下,这样才能让它正常工作。
client.js
文件是本例程最重要的文件,里面包含了实现 WebRTC
一对一通信最核心的逻辑。因此,我们对该文件中的重要函数和关键的语句都做了详细注释。相信通过本章前面讲解的内容,再加上代码中的注释,读者在阅读该文件的代码时不会感到有太大的困难。该文件应该存放在 room.html
目录的 js
子目录下。
首先看一下 room.html
是如何实现的,如代码 5.18 所示。
<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 所示。
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。
'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;