2

一、什么是WebRTC?

众所周知,浏览器本身不支持相互之间直接建立信道进行通信,都是通过服务器进行中转。比如现在有两个客户端,甲和乙,他们俩想要通信,首先需要甲和服务器、乙和服务器之间建立信道。甲给乙发送消息时,甲先将消息发送到服务器上,服务器对甲的消息进行中转,发送到乙处,反过来也是一样。这样甲与乙之间的一次消息要通过两段信道,通信的效率同时受制于这两段信道的带宽。同时这样的信道并不适合数据流的传输,如何建立浏览器之间的点对点传输,一直困扰着开发者。WebRTC应运而生

WebRTC是一个开源项目,旨在使得浏览器能为实时通信(RTC)提供简单的JavaScript接口。说的简单明了一点就是让浏览器提供JS的即时通信接口。这个接口所创立的信道并不是像WebSocket一样,打通一个浏览器与WebSocket服务器之间的通信,而是通过一系列的信令,建立一个浏览器与浏览器之间(peer-to-peer)的信道,这个信道可以发送任何数据,而不需要经过服务器。并且WebRTC通过实现MediaStream,通过浏览器调用设备的摄像头、话筒,使得浏览器之间可以传递音频和视频。

二、WebRTC已经在我们的浏览器中

这么好的功能,各大浏览器厂商自然不会置之不理。现在WebRTC已经可以在较新版的Chrome、Opera和Firefox中使用了,著名的浏览器兼容性查询网站caniuse上给出了一份详尽的浏览器兼容情况。

三、三个接口

WebRTC实现了三个API,分别是:

  • MediaStream:通过MediaStream的API能够通过设备的摄像头及话筒获得视频、音频的同步流
  • RTCPeerConnection:RTCPeerConnection是WebRTC用于构建点对点之间稳定、高效的流传输的组件
  • RTCDataChannel:RTCDataChannel使得浏览器之间(点对点)建立一个高吞吐量、低延时的信道,用于传输任意数据

这里大致上介绍一下这三个API

MediaStream(getUserMedia)

MediaStream API为WebRTC提供了从设备的摄像头、话筒获取视频、音频流数据的功能。

如何调用

同门可以通过调用navigator.getUserMedia(),这个方法接受三个参数:

  1. 一个约束对象(constraints object),这个后面会单独讲
  2. 一个调用成功的回调函数,如果调用成功,传递给它一个流对象
  3. 一个调用失败的回调函数,如果调用失败,传递给它一个错误对象

浏览器兼容性

由于浏览器实现不同,他们经常会在实现标准版本之前,在方法前面加上前缀,所以一个兼容版本就像这样

var getUserMedia = (navigator.getUserMedia || 
                    navigator.webkitGetUserMedia || 
                    navigator.mozGetUserMedia || 
                    navigator.msGetUserMedia); 

一个超级简单的例子

这里写一个超级简单的例子,用来展现getUserMedia的效果:

<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>GetUserMedia实例</title>
</head>
<head>GetUserMedia实例 简单视频</head>
<body>
    <video id="video" autoplay></video>
</body>


<script type="text/javascript">
    var getUserMedia = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia);

    getUserMedia.call(navigator, {
        video: true,
        audio: true
    }, function(localMediaStream) {
        var video = document.getElementById('video');
        // video.src = window.URL.createObjectURL(localMediaStream);
        try {
                video.srcObject = localMediaStream;
            } 
            catch (error)
            {
               video.src = window.URL.createObjectURL(localMediaStream);
            }

        video.onloadedmetadata = function(e) {
            console.log("Label: " + localMediaStream.label);
            console.log("AudioTracks" , localMediaStream.getAudioTracks());
            console.log("VideoTracks" , localMediaStream.getVideoTracks());
        };
    }, function(e) {
        console.log('Reeeejected!', e);
    });
</script>


</html>

打开关闭摄像头

<html>
<body>
<video id="webcam"></video>
<button onClick="openVideo()">开启摄像头</button>
<button onClick="closeVideo()">关闭摄像头</button>
</body>

<script>

var video = document.getElementById('webcam');

function onSuccess(stream) {
    if (window.URL) {
        video.srcObject  = window.srcObject = stream
    } else {
        video.srcObject = stream;
    }

    video.autoplay = true; 
    // 或者 video.play();
}

function onError(stream) {
    console.log('no')
}

if (navigator.getUserMedia) {
    navigator.getUserMedia({video:true}, onSuccess,onError);
} else {
    document.getElementById('webcam').src = '事先准备好的错误视频.mp4';
}

function closeVideo(){
    video.srcObject.getTracks()[0].stop();
    video.srcObject.getTracks()[0].stop();
}

function openVideo(){
    navigator.getUserMedia({video:true}, onSuccess,onError);
}

</script>

</html>

四、RTCPeerConnection

概述

RTCPeerConnection 作为创建点对点连接的 API,是我们实现音视频实时通信的关键。在点对点通信的过程中,需要交换一系列信息,通常这一过程叫做 — 信令signaling)。在信令阶段需要完成的任务:

我们虽然把 WebRTC 称之为点对点的连接,但并不意味着,实现过程中不需要服务器的参与。因为在点对点的信道建立起来之前,二者之间是没有办法通信的。这也就意味着,在信令阶段,我们需要一个通信服务来帮助我们建立起这个连接。WebRTC 本身没有指定信令服务,所以,我们可以但不限于使用 XMPP、XHR、Socket 等来做信令交换所需的服务。我在工作中采用的方案是基于 XMPP 协议的 Strophe.js来做双向通信,但是在本例中则会使用 Socket以及 Koa 来做项目演示。

  • 为每个连接端创建一个 RTCPeerConnection,并添加本地媒体流。
  • 获取并交换本地和远程描述:SDP 格式的本地媒体元数据。
  • 获取并交换网络信息:潜在的连接端点称为 ICE 候选者。

NAT穿越技术

我们先看连接任务的第一条:为每个连接端创建一个RTCPeerConnection,并添加本地媒体流。事实上,如果是一般直播模式,则只需要播放端添加本地流进行输出,其他参与者只需要接受流进行观看即可。

因为各浏览器差异,RTCPeerConnection 一样需要加上前缀。

let PeerConnection = window.RTCPeerConnection ||
    window.mozRTCPeerConnection ||
    window.webkitRTCPeerConnection;
    
let peer = new PeerConnection(iceServers);

我们看见 RTCPeerConnection 也同样接收一个参数 — iceServers,先来看看它长什么样:

    {    
      iceServers: [    
        { url: "stun:stun.l.google.com:19302"}, // 谷歌的公共服务    
        {    
          url: "turn:***",    
          username: ***, // 用户名    
          credential: *** // 密码    
        }    
      ]    
    }

参数配置了两个 url,分别是 STUNTURN,这便是 WebRTC 实现点对点通信的关键,也是一般 P2P 连接都需要解决的问题:NAT穿越。

NAT(Network Address Translation,网络地址转换)简单来说就是为了解决 IPV4 下的 IP 地址匮乏而出现的一种技术,也就是一个 公网 IP 地址一般都对应 n 个内网 IP。这样也就会导致不是同一局域网下的浏览器在尝试 WebRTC 连接时,无法直接拿到对方的公网 IP 也就不能进行通信,所以就需要用到 NAT 穿越(也叫打洞)。以下为 NAT 穿越基本流程

image.png

一般情况下会采用 ICE 协议框架进行 NAT 穿越,ICE 的全称为 Interactive Connectivity Establishment,即交互式连接建立。它使用 STUN 协议以及 TURN 协议来进行穿越。关于 NAT 穿越的更多信息可以参考 ICE协议下NAT穿越的实现(STUN&TURN)、P2P通信标准协议(三)之ICE。

到这里,我们可以发现,WebRTC 的通信至少需要两种服务配合:

  • 信令阶段需要双向通信服务辅助信息交换。
  • STUN、TURN辅助实现 NAT 穿越。
  • 建立点对点连接

WebRTC 的点对点连接到底是什么样的过程呢,我们通过结合图例来分析连接。

image.png

显而易见,在上述连接的过程中:

呼叫端(在这里都是指代浏览器)需要给 接收端 发送一条名为 offer 的信息。 接收端 在接收到请求后,则返回一条 answer 信息给 呼叫端

这便是上述任务之一 ,SDP 格式的本地媒体元数据的交换。sdp 信息一般长这样:

    v=0    
    o=- 1837933589686018726 2 IN IP4 127.0.0.1    
    s=-    
    t=0 0    
    a=group:BUNDLE audio video    
    a=msid-semantic: WMS yvKeJMUSZzvJlAJHn4unfj6q9DMqmb6CrCOT    
    m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126    
    ...    
    ...

但是任务不仅仅是交换,还需要分别保存自己和对方的信息,所以我们再加点料:

image.png

 * **呼叫端** 创建 offer 信息后,先调用 setLocalDescription 存储本地 offer 描述,再将其发送给 **接收端**。    
 * **接收端** 收到 offer 后,先调用 setRemoteDescription 存储远端 offer 描述;然后又创建 answer 信息,同样需要调用 setLocalDescription 存储本地 answer 描述,再返回给 **接收端**    
 * **呼叫端** 拿到 answer 后,再次调用 setRemoteDescription 设置远端 answer 描述。

到这里点对点连接还缺一步,也就是网络信息 ICE 候选交换。不过这一步和 offer、answer 信息的交换并没有先后顺序,流程也是一样的。即:在呼叫端接收端的 ICE 候选信息准备完成后,进行交换,并互相保存对方的信息,这样就完成了一次连接。

image.png

这张图是我认为比较完善的了,详细的描述了整个连接的过程。正好我们再来小结一下:

  • 基础设施:必要的信令服务和 NAT 穿越服务
  • clientA 和 clientB 分别创建 RTCPeerConnection 并为输出端添加本地媒体流。如果是视频通话类型,则意味着,两端都需要添加媒体流进行输出。
  • 本地 ICE 候选信息采集完成后,通过信令服务进行交换。
  • 呼叫端(好比 A 给 B 打视频电话,A 为呼叫端)发起 offer 信息,接收端接收并返回一个 answer 信息,呼叫端保存,完成连接。

本地 1 v 1 对等连接

基础流程讲完了,那么是骡子是马拉出来溜溜。我们先来实现一个本地的对等连接,借此熟悉一下流程和部分 API。本地连接,意思就是不经过服务,在本地页面的两个 video 之间进行连接。算了,还是上图吧,一看就懂。

image.png

image.png

明确一下目标,A 作为输出端,需要获取到本地流并添加到自己的 RTCPeerConnection;B 作为呼叫端,并没有输出的需求,因此只需要接收流。

这基本就是之前重复过好几次的流程用代码写出来而已,看到这里,思路应该比较清晰了。不过有一点需要说明一下,就是现在这种情况,A 作为呼叫端,B 一样是可以拿到 A 的媒体流的。因为连接一旦建立了,就是双向的,只不过 B 初始化 peer 的时候没有添加本地流,所以 A 不会有 B 的媒体流。

网络 1 v 1 对等连接

想必基本流程大家都已经熟悉了,通过图解、实例来来回回讲了好几遍。所以趁热打铁,我们这次把服务加上,做一个真正的点对点连接。在看下面的文章之前,我希望你有一点点 Koa 和 Scoket.io 的基础,了解一些基本 API 即可。不熟悉的同学也不要紧,现在看也来得及,Koa、Socke.io,或者可以参考我之前的文章 Vchat - 一个社交聊天系统(vue + node + mongodb)。

  • 需求

还是老规矩,先了解一下需求。图片加载慢,可以直接看演示地址

image.png

连接过程涉及到多个环节,这里就不一一截图了,可以直接去演示地址查看。简单分析一下我们要做的事情:

  • 加入房间后,获取到房间的所有在线成员。
  • 选择任一成员进行通话,也就是呼叫动作。这时候就有一些细节问题要处理:不能呼叫自己、同一时刻只允许呼叫一个人且需要判断对方是否是通话中、呼叫后回复需要有相应判断(同意、拒绝以及通话中)
  • 拒绝或通话中,都没有后续动作,可以换个人再呼叫。同意之后,就要开始建立点对点连接。

后端比较简单,仅仅是转发一下请求,给对应的客户端。其实我们这个例子的后端,基本都是这个操作,所以后面的后端代码就不贴了,可以去源码直接看。

创建连接

呼叫和回复的逻辑基本清楚了,那我们继续思考,应该在什么时机创建 P2P 连接呢?我们之前说的,拒绝和通话中都不需要处理,只有同意需要,那就应该在同意请求的位置开始创建。需要注意的是,同意请求有两个地方:一个是你点了同意,另一个是对方知道你点了同意之后。

  • 本例采取的是呼叫方发送 Offer,这个地方一定得注意,只要有一方创建 Offer 就可以了,因为一旦连接就是双向的。
socket.on('apply', data => { // 你点同意的地方    
    ...    
    this.$confirm(data.self + ' 向你请求视频通话, 是否同意?', '提示', {    
        confirmButtonText: '同意',    
        cancelButtonText: '拒绝',    
        type: 'warning'    
    }).then(async () => {    
        await this.createP2P(data); // 同意之后创建自己的 peer 等待对方的 offer    
        ... // 这里不发 offer    
    })    
    ...    
});    
socket.on('reply', async data =>{ // 对方知道你点了同意的地方    
    switch (data.type) {    
        case '1': // 只有这里发 offer    
            await this.createP2P(data); // 对方同意之后创建自己的 peer    
            this.createOffer(data); // 并给对方发送 offer    
            break;    
        ...    
    }    
});

五、本地项目测试

简单示例

index.js


// 1、Express 初始化 app 作为 HTTP 服务器的回调函数
var app = require('express')();   
var http = require('http').Server(app)

var io = require('socket.io')(http);


// 2、定义了一个路由 / 来处理首页访问。
/*
app.get('/', function(req, res){
  res.send('<h1>Hello world</h1>');
});
*/
app.get('/', function(req, res) {
  res.sendFile(__dirname + '/index.html');
});

app.get('/webrtc', function(req, res) {
  res.sendFile(__dirname + '/webrtc.html');
});

app.get('/user_media', function(req, res) {
  res.sendFile(__dirname + '/user_media.html');
});


// 3、使 http 服务器监听端口 3000
http.listen(3000, function(){
    console.log('listening on *:3000');
})

// ================== io 通信 =====================
// 客户端页面打开时会和服务端建立连接
/*
io.on('connection', function(socket){
  console.log('a user connected');
});
*/


// 每个 socket 还会触发一个特殊的 disconnect 事件:
/*
io.on('connection', function(socket){
  console.log('a user connected');
  socket.on('disconnect', function(){
    console.log('user disconnected');
  });
});
*/

// 当用户输入消息时,服务器接收一个 chat message 事件
/*
io.on('connection', function(socket){
   console.log('a user connected');

  socket.on('chat message', function(msg){
    console.log('message: ' + msg);
  });
});
*/

// 广播,讲消息发送给所有用户,包括发送者
io.on('connection', function(socket){
  socket.on('chat message', function(msg){

      // 发送消息给客户端(所有客户端都会收到)
    io.emit('chat message', msg);
  });
});

    

启动Node服务

cd /c/work/other/socket-chat

$ node index.js
listening on *:3000

webrtc.html

<html>
    <head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <link rel="stylesheet" href="https://postbird.oschina.io/postbirdalertbox.js/css/bootstrap.min.css"/>
    <script src="https://postbird.oschina.io/postbirdalertbox.js/js/postbirdAlertBox.min.js"></script>
    <link rel="stylesheet" href="https://postbird.oschina.io/postbirdalertbox.js/css/postbirdAlertBox.css">
<title>视频测试页面(本地调试页面)</title>
<h2>本地webrt调试页面</h2>
<br/>

<script>


var request = null;
var hangingGet = null;
var localName;
var server;
var my_id = -1;
var other_peers = {};
var message_counter = 0;
var peer_f_id=-1;


function trace(txt) {
  var elem = document.getElementById("debug");
  elem.innerHTML += txt + "<br>";
}

function handleServerNotification(data) {
  console.log("handleServerNotification"+data);
  trace("Server notification: " + data);
  var parsed = data.split(',');
  if (parseInt(parsed[2]) != 0){
    other_peers[parseInt(parsed[1])] = parsed[0];
        if(isNaN(peer_f_id)||peer_f_id==-1) {
                peer_f_id=parsed[1];
                trace("peer_f_id: " + peer_f_id);
          }
    }
    else{
        if(peer_f_id==parsed[1]) {
                peer_f_id=-1;
      }
    }
}

function handlePeerMessage(peer_id, data) {
  //if(peer_id!=null||!isNaN(peer_id)) {
     peer_f_id = peer_id;
  //}
  processSignalingMessage(data);

  console.log("handlePeerMessage: peer_id"+peer_id+"  data:"+data);
  ++message_counter;
  var str = "Message from '" + other_peers[peer_id] + "'&nbsp;";
  str += "<span id='toggle_" + message_counter + "' onclick='toggleMe(this);' ";
  str += "style='cursor: pointer'>+</span><br>";
  str += "<blockquote id='msg_" + message_counter + "' style='display:none'>";
  str += data + "</blockquote>";
  trace(str);
}

function GetIntHeader(r, name) {
  var val = r.getResponseHeader(name);
  return val != null && val.length ? parseInt(val) : -1;
}

function hangingGetCallback() {
  try {
    if (hangingGet.readyState != 4)
      return;
    if (hangingGet.status != 200) {
      trace("server error: " + hangingGet.statusText);
     // disconnect();
    } else {
      var peer_id = GetIntHeader(hangingGet, "Pragma");
      
      
       console.log("hangingGetCallback: peer_id"+peer_id+"  data:"+hangingGet.responseText);
      if (peer_id == my_id) {
        handleServerNotification(hangingGet.responseText);
      } else {
        handlePeerMessage(peer_id, hangingGet.responseText);
      }
    }

    if (hangingGet) {
      hangingGet.abort();
      hangingGet = null;
    }

    if (my_id != -1)
      window.setTimeout(startHangingGet, 0);
  } catch (e) {
    trace("Hanging get error: " + e.description);
  }
}

function startHangingGet() {
  try {
    hangingGet = new XMLHttpRequest();
    hangingGet.onreadystatechange = hangingGetCallback;
    hangingGet.ontimeout = onHangingGetTimeout;
    hangingGet.open("GET", server + "/wait?peer_id=" + my_id, true);
    hangingGet.send();  
  } catch (e) {
    trace("error" + e.description);
  }
}

function onHangingGetTimeout() {
  trace("hanging get timeout. issuing again.");
  hangingGet.abort();
  hangingGet = null;
  if (my_id != -1)
    window.setTimeout(startHangingGet, 0);
}


/**
 * 
 *signIn 回调函数
 **/
function signInCallback() {
  try {
    if (request.readyState == 4) {
      if (request.status == 200) {
          // hello,51,1 \n lily,50,1 \n lily,49,1 \n lily,48,1
        var peers = request.responseText.split("\n"); 
        my_id = parseInt(peers[0].split(',')[1]);
        trace("My id: " + my_id);
                if(peers.length>1)
                {
                            peer_f_id = parseInt(peers[1].split(',')[1]);
              trace("peer_f_id: " + peer_f_id);
                }
        for (var i = 1; i < peers.length; ++i) {
          if (peers[i].length > 0) {
            trace("Peer" + i + " : " + peers[i]);
            var parsed = peers[i].split(',');
            other_peers[parseInt(parsed[1])] = parsed[0];
          }
        }
        // 开启请求
        startHangingGet();
        request = null;
      }
    }
  } catch (e) {
    trace("error: " + e.description);
  }
}


function signIn() {
  try {
    request = new XMLHttpRequest();
    request.onreadystatechange = signInCallback;   // 请求完之后的回调函数,进行参数处理
    // 登录请求
    request.open("GET", server + "/sign_in?" + localName, true);
    request.send();

    console.log(request);
  } catch (e) {
    trace("error: " + e.description);
  }
    
  // 登录之后,再初始化参数
    setTimeout(initialize, 1); 
}

function sendToPeer(peer_id, data) {

  console.log("sendToPeer: peer_id:"+peer_id+"  data:"+data);
       
  ++message_counter;
  var str = "Message send to '" + other_peers[peer_id] + "'&nbsp;";
  str += "<span id='toggle_" + message_counter + "' onclick='toggleMe(this);' ";
  str += "style='cursor: pointer'>+</span><br>";
  str += "<blockquote id='msg_" + message_counter + "' style='display:none'>";
  str += data + "</blockquote>";
  trace(str);
       
  if (my_id == -1) {
    alert("Not connected");
    return;
  }
  if (peer_id == my_id) {
    alert("Can't send a message to oneself :)");
    return;
  }
  var r = new XMLHttpRequest();
  r.open("POST", server + "/message?peer_id=" + my_id + "&to=" + peer_id,
         false);
  r.setRequestHeader("Content-Type", "text/plain");
  r.send(data);
  r = null;
}

function connect() {
  localName = document.getElementById("local").value.toLowerCase();
  server = document.getElementById("server").value.toLowerCase();
  if (localName.length == 0) {
    alert("I need a name please.");
    document.getElementById("local").focus();
  } else {
    document.getElementById("connect").disabled = true;
    signIn();
  }
}

function disconnect() {
  if (request) {
    request.abort();
    request = null;
  }
  
  if (hangingGet) {
    hangingGet.abort();
    hangingGet = null;
  }

  if (my_id != -1) {
    request = new XMLHttpRequest();
    request.open("GET", server + "/sign_out?peer_id=" + my_id, false);
    request.send();
    request = null;
    my_id = -1;
  }

  document.getElementById("connect").disabled = false;
}

window.onbeforeunload = disconnect;

function send() {
  var text = "";
  var peer_id = parseInt(document.getElementById("peer_id").value);
  if (!text.length || peer_id == 0) {
    alert("No text supplied or invalid peer id");
  } else {
    sendToPeer(peer_id, text);
  }
}

function toggleMe(obj) {
  var id = obj.id.replace("toggle", "msg");
  var t = document.getElementById(id);
  if (obj.innerText == "+") {
    obj.innerText = "-";
    t.style.display = "block";
  } else {
    obj.innerText = "+";
    t.style.display = "none";
  }
}




        var pc;
            var main; // 视频的DIV
            var errorNotice; // 错误提示DIV
            var socket; // websocket
            var localVideo; // 本地视频
            var miniVideo; // 本地小窗口
            var remoteVideo; // 远程视频
            var localStream; // 本地视频流
            var remoteStream; // 远程视频流
            var localVideoUrl; // 本地视频地址
            var started = false; // 是否开始
            var has_video=true;
            var channelOpenTime;
            var channelCloseTime;
            
            var PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
            
           //兼容不同浏览器获取到用户媒体对象
           var getUserMediaobj = (navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia || navigator.mediaDevices.getUserMedia);
        
            // 初始化
            function initialize() {
                console.log("初始化");
                
                main = document.getElementById("main");
                errorNotice = document.getElementById("errorNotice");
                localVideo = document.getElementById("localVideo");
                miniVideo = document.getElementById("miniVideo");
                remoteVideo = document.getElementById("remoteVideo");
                
                // 获取流媒体
                getUserMedia(true);
            }
            
            // 获取用户的媒体
            function getUserMedia(add_video) {
                console.log("获取用户媒体");
                
                 navigator.mediaDevices.getUserMedia({
                     "audio" : true,
                     "video" : add_video
                 }).then(onUserMediaSuccess).catch(onUserMediaError);
            }
            
            // 获取用户流失败
            function onUserMediaError(error) {
                console.log("获取用户流失败!");
                trace(error);
                if(has_video){
                   has_video=false;
                   getUserMedia(false);
                }
            }
            
            // 获取用户媒体成功
            function onUserMediaSuccess(stream) {
            
                    
                try{
                    var url = webkitURL.createObjectURL(stream);
                    localVideoUrl = url;
                }
                catch(e){
                    localStream = stream;
                }
                localVideo.style.display = "inline-block";
                remoteVideo.style.display = "none";
            }
            
            // 开始连接
            function maybeStart() {

                // 判断是否已经在连接了
                if (!started ) {
                    setNotice("连接中...");

                    // doc:https://blog.csdn.net/qq_41875664/article/details/98870798
                    // 建立RTCPeerConnection,然后连接后各自都能拿到对方的视频流
                    createPeerConnection();
                    if(localStream!=null&&localStream!=undefined)
                      // 本地视频流添加到流中
                      pc.addStream(localStream);
                    started = true;
                }
                else
                {
                    setNotice("占线中,请稍后再试...");
                }
            }
            
            // 开始通话
            function doCall() {
                console.log("开始呼叫");
                pc.createOffer(setLocalAndSendMessage, function (error) {console.log("发送信令失败:" + error); });
            }
            
            function setLocalAndSendMessage(sessionDescription) {
                pc.setLocalDescription(sessionDescription);
                sendMessage(sessionDescription);
            }
            
            // 发送信息
            function sendMessage(message) {
                var msgJson = JSON.stringify(message);
                 sendToPeer(peer_f_id, msgJson);
                console.log("发送信息 : " + msgJson);
            }
            
                    
            // 打开连接
            function createPeerConnection() {
                // turn:192.168.1.107:3478?transport=tcp
                // turn:127.0.0.1:10290?transport=tcp
        var server = { "iceServers": [ {"url": "turn:192.168.10.160:3478?transport=tcp", "username": "user", "credential": "hm88888888" } ] };
        
                pc = new PeerConnection(server);//server
                pc.onicecandidate = onIceCandidate;   // 监听ICE候选信息 如果收集到,就发送给对方
                pc.onconnecting = onSessionConnecting;
                pc.onopen = onSessionOpened;
                pc.onaddstream = onRemoteStreamAdded;  // 添加远程视频流到本地
                pc.onremovestream = onRemoteStreamRemoved;
            }
            
            // 谁知状态
            function setNotice(msg) {
                document.getElementById("footer").innerHTML = msg;
            }
            
            // 响应
            function doAnswer() {
                pc.createAnswer(setLocalAndSendMessage,  function (error) {
                    console.log("响应信令失败:" + error);
                });
            }
            

            // 视频呼叫请求
            function sendvideo(){
            
                var peer_id = parseInt(document.getElementById("peer_id").value);        
                if ( (!isNaN(peer_f_id))&&peer_id > 0) {
                    peer_f_id=peer_id;
                    trace("peer_f_id: " + peer_f_id);
                 }
                maybeStart();
                sendMessage({"type":"docall"});
                //doCall();
            }
            
            // websocket收到消息
            function onChannelMessage(message) {
                console.log("收到信息 : " + message.data);
                
            }
            
            // 处理消息
            function processSignalingMessage(message) {

                var msg = JSON.parse(message);
                if (msg.type === "docall") {
                    if (!started)
                        maybeStart();
                    doCall();
                }
                else if (msg.type === "offer") {
                    // 发起一个请求
                    console.log("=== 发起一个offer ===");
                    pc.setRemoteDescription(new RTCSessionDescription(msg),
                                            function(){

                                                doAnswer();
                                                
                                            });
                                        
                } else if (msg.type === "answer" && started) {

                    /*
                    console.log("=== 接收一个answer ===");
                    // 是否接受通话,这里需要做判断,给出提示
                    var statu = confirm("需要接受通话吗?"); //在js里面写confirm,在页面中弹出提示信息。
                    if(status){
                            console.log("接受了连接请求!");
                        }
                    */

                    PostbirdAlertBox.confirm({
                        'title': '视频请求',
                        'content': '向你请求视频通话,是否同意',
                        'okBtn': '同意',
                        'cancelBtn':'拒绝',
                        'contentColor': 'red',
                        'onConfirm': function () {
                            console.log("OK - 回调触发后隐藏提示框");
                            // alert("OK - 回调触发后隐藏提示框");

                            console.log("msg:", msg);

                            // 确认通过接受
                            pc.setRemoteDescription(new RTCSessionDescription(msg));
                        },
                        'onCancel': function () {
                            console.log("Cancel-回调触发后隐藏提示框");
                            // alert("Cancel-回调触发后隐藏提示框");
                        }
                     });
                } else if(msg.candidate!=null && started){
                                    var candidate = new RTCIceCandidate(msg);
                                    pc.addIceCandidate(candidate);
        }
            }
            
            // websocket异常
            function onChannelError(event) {
                console.log("websocket异常 : " + event);
                
                //alert("websocket异常");
            }
            
            
            // 邀请聊天:这个不是很清楚,应该是对方进入聊天室响应函数
            function onIceCandidate(event) {
            
                console.log("event.candidate :"+ JSON.stringify(event.candidate));
                 // 监听ICE候选信息 如果收集到,就发送给对方
                if (event.candidate) {
                    sendMessage({
                        sdpMLineIndex : event.candidate.sdpMLineIndex,
                        sdpMid : event.candidate.sdpMid,
                        candidate : event.candidate.candidate
                    });
                } else {
                    console.log("End of candidates.");
                }
            }
            
            // 开始连接
            function onSessionConnecting(message) {
                console.log("开始连接");
            }
            
            // 连接打开
            function onSessionOpened(message) {
                console.log("连接打开");
            }
            
            // 远程视频添加
            function onRemoteStreamAdded(event) {
                console.log("远程视频添加");
                trace("远程视频添加");
                    try{
                        var url = webkitURL.createObjectURL(event.stream);
                        console.log("远程视频流url:", url)
                            
                        trace(url);
                        remoteVideo.src = url;
                        miniVideo.src = localVideoUrl;
                    }
                    catch(e){
                        remoteVideo.srcObject = event.stream;
                        console.log("远程视频流e-bug:", e)
                    }
                remoteVideo.style.display = "inline-block";
                remoteStream = event.stream;
                waitForRemoteVideo();
            }
            
            // 远程视频移除
            function onRemoteStreamRemoved(event) {
                console.log("远程视频移除");
            }
            
            // 远程视频关闭
            function onRemoteClose() {
                started = false;
                initiator = false;
                
                miniVideo.style.display = "none";
                remoteVideo.style.display = "none";
                localVideo.style.display = "inline-block";
                
                main.style.webkitTransform = "rotateX(360deg)";
                
                miniVideo.src = "";
                remoteVideo.src = "";
                localVideo.src = localVideoUrl;
                
                setNotice("对方已断开!");
                
                pc.close();
            }
            
            // 等待远程视频
            function waitForRemoteVideo() {
                
                if (remoteVideo.currentTime > 0) { // 判断远程视频长度
                    trace("remoteVideo.currentTime:"+remoteVideo.currentTime);
                    transitionToActive();
                } else {
                    setTimeout(waitForRemoteVideo, 100);
                }
            }
            
            // 接通远程视频
            function transitionToActive() {
                remoteVideo.style.display = "inline-block";
                localVideo.style.display = "none";
                main.style.webkitTransform = "rotateX(360deg)";
                setTimeout(function() {
                    localVideo.src = "";
                }, 500);
                setTimeout(function() {
                    miniVideo.style.display = "inline-block";
                    //miniVideo.style.display = "none";
                }, 1000);
                
                setNotice("连接成功!");
            }
            
            // 全屏
            function fullScreen() {
                main.webkitRequestFullScreen();
            }
        

            // 设置浏览器支持提示信息
            function errorNotice(msg) {
                main.style.display = "none";
                
                errorNotice.style.display = "block";
                errorNotice.innerHTML = msg;
            }
            
            
      function setaudiostop(){
         if(localStream.getAudioTracks()[0].enabled)
            localStream.getAudioTracks()[0].enabled=false;
         else
            localStream.getAudioTracks()[0].enabled=true;
          
      }
            
</script>

</head>
<body ondblclick="fullScreen()">
Server: <input type="text" id="server" value="http://192.168.10.160:8089"><br>
https测试服务地址(本地):http://192.168.10.160:8089 <br>
<br>
Your name: <input type="text" id="local" value="hm1234">
<button id="connect" onclick="connect();">Connect</button>

<button id="stopaudio" onclick="setaudiostop();">静音</button>


<table><tbody><tr><td>
Target peer id: <input type="text" id="peer_id" size="3"></td><td>
<button id="sendvideo" onclick="sendvideo();">sendvideo</button>
</td></tr></tbody></table>
<button onclick="document.getElementById(&#39;debug&#39;).innerHTML=&#39;&#39;;">
Clear log</button>

<pre id="debug"></pre>
<br><hr>
<div id="main">
<video id="localVideo" autoplay="autoplay"></video>
<video id="remoteVideo" autoplay="autoplay"></video>
<video id="miniVideo" autoplay="autoplay"></video>
</div>

<div id="footer"></div>
</body>
</html>

访问页面
http://localhost:3000/webrtc

打开页面:
image.png

新开一个页面:
image.png

我们可以看到,在Lily页面输入Red用户的My id,即可建立视频通讯。

image.png

远程1对1示例(vue+socketio+webrtc)

remote1.vue

<template>
    <div class="remote1"
         v-loading="loading"
         :element-loading-text="loadingText"
         element-loading-spinner="el-icon-loading"
         element-loading-background="rgba(0, 0, 0, 0.8)"
    >
        <div class="shade" v-if="!isJoin">
            <div class="input-container">
                <input type="text" v-model="account" placeholder="请输入你的昵称" @keyup.enter="join">
                <button @click="join">确定</button>
            </div>
        </div>
        <div class="userList">
            <h5>在线用户:{{userList.length}}</h5>
            <p v-for="v in userList" :key="v.account">
                {{v.account}}
                <i v-if="v.account === account || v.account === isCall">
                {{v.account === account ? 'me' : ''}}
                {{v.account === isCall ? 'calling' : ''}}
                </i>
                <span @click="apply(v.account)" v-if="v.account !== account && v.account !== isCall">呼叫 {{v.account}}</span>
            </p>
        </div>
        <div class="video-container" v-show="isToPeer">
            <div>
                <video src="" id="rtcA" controls autoplay></video>
                <h5>{{account}}</h5>
                <button @click="hangup">hangup</button>
            </div>
            <div>
                <video src="" id="rtcB" controls autoplay></video>
                <h5>{{isCall}}</h5>
            </div>
        </div>
    </div>
</template>
<script>
    import socket from '../../utils/socket';
    export default{
        name: 'remote1',
        data() {
            return {
                account: window.sessionStorage.account || '',
                isJoin: false,
                userList: [],
                roomid: 'webrtc_1v1', // 指定房间ID
                isCall: false, // 正在通话的对象
                loading: false,
                loadingText: '呼叫中',
                isToPeer: false, // 是否建立了 P2P 连接
                peer: null,
                offerOption: {
                    offerToReceiveAudio: 1,
                    offerToReceiveVideo: 1
                }
            };
        },
        methods: {
            // 初始化socketio连接后,如果没有加入,则需要加入账号,这时会新产生一个ID
            // 新产生的用户ID会保存在客户端sessionStorage
            /**
             * localStorage 和 sessionStorage 属性允许在浏览器中存储 key/value 对的数据。
               sessionStorage 用于临时保存同一窗口(或标签页)的数据,在关闭窗口或标签页之后将会删除这些数据。
               提示: 如果你想在浏览器窗口关闭后还保留数据,可以使用 localStorage 属性, 该数据对象没有过期时间,今天、下周、明年都能用,除非你手动去删除。
             */
            join() {
                if (!this.account) return;
                this.isJoin = true;
                window.sessionStorage.account = this.account;   // 使用 sessionStorage 创建一个本地存储的 name/value 对
                socket.emit('join', {roomid: this.roomid, account: this.account});
            },

            // socket监听事件初始化(监听服务器)
            initSocket() {

                // 服务端加入房间成功后,发送给客户端的相应事件,客户端会自动刷新用户列表
                socket.on('joined', (data) =>{
                    this.userList = data;
                });

                socket.on('reply', async data =>{ // 收到回复
                    this.loading = false;
                    console.log(data);
                    switch (data.type) {
                        case '1': // 同意
                            this.isCall = data.self;
                            // 对方同意之后创建自己的 peer
                            await this.createP2P(data);
                            // 并给对方发送 offer
                            this.createOffer(data);
                            break;
                        case '2': //拒绝
                            this.$message({
                                message: '对方拒绝了你的请求!',
                                type: 'warning'
                            });
                            break;
                        case '3': // 正在通话中
                            this.$message({
                                message: '对方正在通话中!',
                                type: 'warning'
                            });
                            break;
                    }
                });

                // 收到呼叫转发的事件,是否同意接受通话(根据账号来获取不通的客户端socketio对象)
                socket.on('apply', data => { // 收到请求
                    if (this.isCall) {
                        this.reply(data.self, '3');   // 如果正在通话中,需要返回给服务端,服务端再做转发
                        return;
                    }
                    this.$confirm(data.self + ' 向你请求视频通话, 是否同意?', '提示', {
                        confirmButtonText: '同意',
                        cancelButtonText: '拒绝',
                        type: 'warning'
                    }).then(async () => {
                        await this.createP2P(data); // 同意之后创建自己的 peer 等待对方的 offer
                        this.isCall = data.self;
                        this.reply(data.self, '1');
                    }).catch(() => {
                        this.reply(data.self, '2');
                    });
                });
                
                socket.on('1v1answer', (data) =>{ // 接收到 answer
                    this.onAnswer(data);
                });
                socket.on('1v1ICE', (data) =>{ // 接收到 ICE
                    this.onIce(data);
                });
                socket.on('1v1offer', (data) =>{ // 接收到 offer
                    this.onOffer(data);
                });
                socket.on('1v1hangup', _ =>{ // 通话挂断
                    this.$message({
                        message: '对方已断开连接!',
                        type: 'warning'
                    });
                    this.peer.close();
                    this.peer = null;
                    this.isToPeer = false;
                    this.isCall = false;
                });
            },

            hangup() { // 挂断通话
                socket.emit('1v1hangup', {account: this.isCall, self: this.account});
                this.peer.close();
                this.peer = null;
                this.isToPeer = false;
                this.isCall = false;
            },
            // 呼叫事件
            apply(account) {
                // account 对方account  self 是自己的account
                this.loading = true;
                this.loadingText = '呼叫中';
                socket.emit('apply', {account: account, self: this.account});
            },
            reply(account, type) {
                socket.emit('reply', {account: account, self: this.account, type: type});
            },
            async createP2P(data) {
                this.loading = true;
                this.loadingText = '正在建立通话连接';
                await this.createMedia(data);
            },
            async createMedia(data) {
                // 保存本地流到全局
                try {
                    this.localstream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
                    let video = document.querySelector('#rtcA');
                    video.srcObject = this.localstream;
                } catch (e) {
                    console.log('getUserMedia: ', e)
                }
                this.initPeer(data); // 获取到媒体流后,调用函数初始化 RTCPeerConnection
            },
            initPeer(data) {
                // 创建输出端 PeerConnection
                let PeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
                this.peer = new PeerConnection();
                this.peer.addStream(this.localstream); // 添加本地流
                // 监听ICE候选信息 如果收集到,就发送给对方
                this.peer.onicecandidate = (event) => {
                    if (event.candidate) {
                        socket.emit('1v1ICE', {account: data.self, self: this.account, sdp: event.candidate});
                    }
                };
                this.peer.onaddstream = (event) => { // 监听是否有媒体流接入,如果有就赋值给 rtcB 的 src
                    this.isToPeer = true;
                    this.loading = false;
                    let video = document.querySelector('#rtcB');
                    video.srcObject = event.stream;
                };
            },
            async createOffer(data) { // 创建并发送 offer
                try {
                    // 创建offer
                    let offer = await this.peer.createOffer(this.offerOption);
                    // 呼叫端设置本地 offer 描述
                    await this.peer.setLocalDescription(offer);
                    // 给对方发送 offer
                    socket.emit('1v1offer', {account: data.self, self: this.account, sdp: offer});
                } catch (e) {
                    console.log('createOffer: ', e);
                }
            },
            async onOffer(data) { // 接收offer并发送 answer
                try {
                    // 接收端设置远程 offer 描述
                    await this.peer.setRemoteDescription(data.sdp);
                    // 接收端创建 answer
                    let answer = await this.peer.createAnswer();
                    // 接收端设置本地 answer 描述
                    await this.peer.setLocalDescription(answer);
                    // 给对方发送 answer
                    socket.emit('1v1answer', {account: data.self, self: this.account, sdp: answer});
                } catch (e) {
                    console.log('onOffer: ', e);
                }
            },
            async onAnswer(data) { // 接收answer
                try {
                    await this.peer.setRemoteDescription(data.sdp); // 呼叫端设置远程 answer 描述
                } catch (e) {
                    console.log('onAnswer: ', e);
                }
            },
            async onIce(data) { // 接收 ICE 候选
                try {
                    await this.peer.addIceCandidate(data.sdp); // 设置远程 ICE
                } catch (e) {
                    console.log('onAnswer: ', e);
                }
            }
        },
        mounted() {
            this.initSocket();
            console.log("init socket end...");
            if (this.account) {
                this.join();
            }
        }
    }
</script>
<style lang="scss" scoped>
    .remote1{
        width: 100%;
        height: 100%;
        display: flex;
        justify-content: flex-start;
    }
    .shade{
        position: fixed;
        width:100vw;
        height: 100vh;
        left: 0;
        top:0;
        z-index: 100;
        background-color: rgba(0,0,0,0.9);
        .input-container{
            position: absolute;
            left:50%;
            top:30%;
            transform: translate(-50%, 50%);
            display: flex;
            justify-content: space-between;
            align-items: center;
            input{
                margin: 0;
            }
        }
    }
    .userList{
        border: 1px solid #ddd;
        margin-right: 50px;
        h5{
            text-align: left;
            margin-bottom: 5px;
        }
        p{
            border-bottom: 1px solid #ddd;
            line-height: 32px;
            width:200px;
            position: relative;
            overflow: hidden;
            cursor: pointer;
            span{
                position: absolute;
                left:0;
                top:100%;
                background-color: #1fbeca;
                color: #fff;
                height: 100%;
                transition: top 0.2s;
                display: block;
                width: 100%;
            }
            i{
                font-style: normal;
                font-size: 11px;
                border: 1px solid #1fbeca;
                color: #27cac7;
                border-radius: 2px;
                line-height: 1;
                display: block;
                position: absolute;
                padding: 1px 2px;
                right: 5px;
                top: 5px;
            }
        }
        p:last-child{
          border-bottom: none;
        }
        p:hover span{
            top:0;
        }
    }
    .video-container{
        display: flex;
        justify-content: center;
        video{
            width: 400px;
            height: 300px;
            margin-left: 20px;
            background-color: #ddd;
        }
    }
</style>

server.js

/**
 * Created by wyw on 2018/10/14.
 */
const Koa = require('koa');
const path = require('path');
const koaSend = require('koa-send');
const static = require('koa-static');
const socket = require('koa-socket');
const users = {}; // 保存用户
const sockS = {}; // 保存客户端对应的socket
var clientCount = 0   // 客户数
const io = new socket({
    ioOptions: {
        pingTimeout: 10000,
        pingInterval: 5000,
    }
});

// 创建一个Koa对象表示web app本身:
const app = new Koa();
// socket注入应用
io.attach(app);
app.use(static(
    path.join( __dirname,  './public')
));

// 对于任何请求,app将调用该异步函数处理请求:
app.use(async (ctx, next) => {
    if (!/\./.test(ctx.request.url)) {
        await koaSend(
            ctx,
            'index.html',
            {
                root: path.join(__dirname, './'),
                maxage: 1000 * 60 * 60 * 24 * 7,
                gzip: true,
            } // eslint-disable-line
        );
    } else {
        await next();
    }
});
// io.on('join', ctx=>{ // event data socket.id
// });
app._io.on( 'connection', sock => {

  // 给每个用户取名字
  clientCount++
  console.log('a user connected:' + clientCount);

    // 客户端账号加入room
    sock.on('join', data=>{
        sock.join(data.roomid, () => {
            if (!users[data.roomid]) {
                users[data.roomid] = [];
            }
            let obj = {
                account: data.account,
                id: sock.id
            };

            // 过滤掉相同账号的
            let arr = users[data.roomid].filter(v => v.account === data.account);
            if (!arr.length) {
                users[data.roomid].push(obj);
            }

            // 保存客户端对应的socket
            sockS[data.account] = sock;
            app._io.in(data.roomid).emit('joined', users[data.roomid], data.account, sock.id); // 发给房间内所有人

            console.log( data.account + " join room")
            console.log("sock.id="+sock.id)

            // sock.to(data.roomid).emit('joined',data.account);
        });
    });

     // 1 v 1(呼叫事件)
     sock.on('apply', data=>{ // 转发申请
        // 呼叫之后,将信息转发给对应的客户,注意这里是根据账号获取到对应的socket对象的
        sockS[data.account].emit('apply', data);
    });

    sock.on('offer', data=>{
        console.log('offer', data);
        sock.to(data.roomid).emit('offer',data);
    });
    sock.on('answer', data=>{
         console.log('answer', data);
        sock.to(data.roomid).emit('answer',data);
    });
    sock.on('__ice_candidate', data=>{
         console.log('__ice_candidate', data);
        sock.to(data.roomid).emit('__ice_candidate',data);
    });

    sock.on('reply', data=>{ // 转发回复
        sockS[data.account].emit('reply', data);
    });
    sock.on('1v1answer', data=>{ // 转发 answer
        sockS[data.account].emit('1v1answer', data);
    });
    sock.on('1v1ICE', data=>{ // 转发 ICE
        sockS[data.account].emit('1v1ICE', data);
    });
    sock.on('1v1offer', data=>{ // 转发 Offer
        sockS[data.account].emit('1v1offer', data);
    });
    sock.on('1v1hangup', data=>{ // 转发 hangup
        sockS[data.account].emit('1v1hangup', data);
    });
});
app._io.on('disconnect', (sock) => {
    for (let k in users) {
        users[k] = users[k].filter(v => v.id !== sock.id);
    }
    console.log(`disconnect id => ${users}`);
});

// 在端口3001监听:
let port = 3001;
app.listen(port, _ => {
    console.log('app started at port ...' + port);
});
// https.createServer(app.callback()).listen(3001);

通话界面:
image.png


相关文章:
Webrtc超详细介绍
使用WebRTC搭建前端视频聊天室——入门篇
使用WebRTC搭建前端视频聊天室——信令篇
从头到脚撸一个多人视频聊天 — WebRTC 实战(一)
外网资料:Web Real-Time Communication (WebRTC)


Corwien
6.3k 声望1.6k 粉丝

为者常成,行者常至。