一、什么是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(),这个方法接受三个参数:
- 一个约束对象(constraints object),这个后面会单独讲
- 一个调用成功的回调函数,如果调用成功,传递给它一个流对象
- 一个调用失败的回调函数,如果调用失败,传递给它一个错误对象
浏览器兼容性
由于浏览器实现不同,他们经常会在实现标准版本之前,在方法前面加上前缀,所以一个兼容版本就像这样
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,分别是 STUN
和 TURN
,这便是 WebRTC 实现点对点通信的关键,也是一般 P2P 连接都需要解决的问题:NAT穿越。
NAT(Network Address Translation,网络地址转换)简单来说就是为了解决 IPV4 下的 IP 地址匮乏而出现的一种技术,也就是一个 公网 IP 地址一般都对应 n 个内网 IP。这样也就会导致不是同一局域网下的浏览器在尝试 WebRTC 连接时,无法直接拿到对方的公网 IP 也就不能进行通信,所以就需要用到 NAT 穿越(也叫打洞)。以下为 NAT 穿越基本流程:
一般情况下会采用 ICE 协议框架进行 NAT 穿越,ICE 的全称为 Interactive Connectivity Establishment
,即交互式连接建立
。它使用 STUN 协议以及 TURN 协议来进行穿越。关于 NAT 穿越的更多信息可以参考 ICE协议下NAT穿越的实现(STUN&TURN)、P2P通信标准协议(三)之ICE。
到这里,我们可以发现,WebRTC 的通信至少需要两种服务配合:
- 信令阶段需要双向通信服务辅助信息交换。
- STUN、TURN辅助实现 NAT 穿越。
- 建立点对点连接
WebRTC 的点对点连接到底是什么样的过程呢,我们通过结合图例来分析连接。
显而易见,在上述连接的过程中:
呼叫端(在这里都是指代浏览器)需要给 接收端 发送一条名为 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
...
...
但是任务不仅仅是交换,还需要分别保存自己和对方的信息,所以我们再加点料:
* **呼叫端** 创建 offer 信息后,先调用 setLocalDescription 存储本地 offer 描述,再将其发送给 **接收端**。
* **接收端** 收到 offer 后,先调用 setRemoteDescription 存储远端 offer 描述;然后又创建 answer 信息,同样需要调用 setLocalDescription 存储本地 answer 描述,再返回给 **接收端**
* **呼叫端** 拿到 answer 后,再次调用 setRemoteDescription 设置远端 answer 描述。
到这里点对点连接还缺一步,也就是网络信息 ICE 候选交换。不过这一步和 offer、answer 信息的交换并没有先后顺序,流程也是一样的。即:在呼叫端和接收端的 ICE 候选信息准备完成后,进行交换,并互相保存对方的信息,这样就完成了一次连接。
这张图是我认为比较完善的了,详细的描述了整个连接的过程。正好我们再来小结一下:
- 基础设施:必要的信令服务和 NAT 穿越服务
- clientA 和 clientB 分别创建 RTCPeerConnection 并为输出端添加本地媒体流。如果是视频通话类型,则意味着,两端都需要添加媒体流进行输出。
- 本地 ICE 候选信息采集完成后,通过信令服务进行交换。
- 呼叫端(好比 A 给 B 打视频电话,A 为呼叫端)发起 offer 信息,接收端接收并返回一个 answer 信息,呼叫端保存,完成连接。
本地 1 v 1 对等连接
基础流程讲完了,那么是骡子是马拉出来溜溜。我们先来实现一个本地的对等连接,借此熟悉一下流程和部分 API。本地连接,意思就是不经过服务,在本地页面的两个 video 之间进行连接
。算了,还是上图吧,一看就懂。
明确一下目标,A 作为输出端,需要获取到本地流并添加到自己的 RTCPeerConnection
;B 作为呼叫端,并没有输出的需求,因此只需要接收流。
这基本就是之前重复过好几次的流程用代码写出来而已,看到这里,思路应该比较清晰了。不过有一点需要说明一下,就是现在这种情况,A 作为呼叫端,B 一样是可以拿到 A 的媒体流的。因为连接一旦建立了,就是双向的,只不过 B 初始化 peer 的时候没有添加本地流,所以 A 不会有 B 的媒体流。
网络 1 v 1 对等连接
想必基本流程大家都已经熟悉了,通过图解、实例来来回回讲了好几遍。所以趁热打铁,我们这次把服务加上,做一个真正的点对点连接。在看下面的文章之前,我希望你有一点点 Koa 和 Scoket.io 的基础,了解一些基本 API 即可。不熟悉的同学也不要紧,现在看也来得及,Koa、Socke.io,或者可以参考我之前的文章 Vchat - 一个社交聊天系统(vue + node + mongodb)。
- 需求
还是老规矩,先了解一下需求。图片加载慢,可以直接看演示地址
连接过程涉及到多个环节,这里就不一一截图了,可以直接去演示地址查看。简单分析一下我们要做的事情:
- 加入房间后,获取到房间的所有在线成员。
- 选择任一成员进行通话,也就是呼叫动作。这时候就有一些细节问题要处理:不能呼叫自己、同一时刻只允许呼叫一个人且需要判断对方是否是通话中、呼叫后回复需要有相应判断(同意、拒绝以及通话中)
- 拒绝或通话中,都没有后续动作,可以换个人再呼叫。同意之后,就要开始建立点对点连接。
后端比较简单,仅仅是转发一下请求,给对应的客户端。其实我们这个例子的后端,基本都是这个操作,所以后面的后端代码就不贴了,可以去源码直接看。
创建连接
呼叫和回复的逻辑基本清楚了,那我们继续思考,应该在什么时机创建 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] + "' ";
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] + "' ";
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('debug').innerHTML='';">
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
打开页面:
新开一个页面:
我们可以看到,在Lily页面输入Red用户的My id,即可建立视频通讯。
远程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);
通话界面:
相关文章:
Webrtc超详细介绍
使用WebRTC搭建前端视频聊天室——入门篇
使用WebRTC搭建前端视频聊天室——信令篇
从头到脚撸一个多人视频聊天 — WebRTC 实战(一)
外网资料:Web Real-Time Communication (WebRTC)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。