技术前言
前次教程我们一起用NodeJs做为服务端,用纯javascript简单实现了一个基于文本的P2P通讯,两个浏览器终端之间基于Socket.IO技术进行普通文本信息互相传输,其实已实现了一个简单聊天室的基本技术模型。本次教程我们基于上次教程的内容继续探讨WebRTC视频通讯,实现不同浏览器之间进行视频通讯。大家注意本系列教程是采用循序渐进的方式,每次教程的内容都是下次教程的铺垫。WebRTC视频通讯主要是用RTCPeerConnection通讯实现与其它终端的长连接完成双方的视频数据传输。
RTCPeerConnection连接过程图
RTCPeerConnection视频流数据传输完整的生命周期还是比较清晰,如上图所示终端A和终端B在初始化的时候各自创建RTCPeerConnection对像准备数据通道。终端A完成初始化后马上向终端B发送Call信令(标明媒体数据已准备就绪),终端B收到Call后发送Offer信令并设置setLocalDescription属性,注意当setLocalDescription属性改变时会触发onicecandidate事件,通常在onicecandidate事件中实现向对方发送candidate数据的业务逻辑。此时终端A收到Offer后设置自身的setRemoteDescription属性,并且执行createAnswer操作设置setLocalDescription属性后立刻发送Answer信令。终端B收到Answer后设置自身的setRemoteDescription属性同时触发onaddstream事件。在onaddstream事件中实现将远程视频流加载到本地视频容器中的操作。
至此终端A到终端B的P2P视频通讯完成。
接口方法
RTCPeerConnection 中提供的接口很多,大家可以去官网上查询。
我这里主要说的是有关本教程中的接口,也是最常用的接口。
1. createOffer
在 CreateOffer 中,会获取本地所支持的音视频编码格式,以及传输相关参数信息,一般在此方法中要设置setLocalDescription属性完成RTC对本地视频数据加载。
2. setLocalDescription
设置改变与本地连接关联的本地视频描述。此描述信息中包括媒体格式。如果setLocalDescription()在连接已经建立时被调用,则表示正在进行重新协商(可能是为了适应不断变化的网络状况)。因为在两个对等方就配置达成一致之前将交换描述,所以通过调用提交的描述setLocalDescription()不会立即生效。相反,当前的连接配置将保持不变,直到协商完成。只有这样,商定的配置才会生效。
3. createAnswer
在WebRTC连接期间createAnswer()方法是针对收到的Offer信令后创建 SDP答复信令并回复远程终端。此SDP答复包含有关会话中已附加的所有媒体参数,如浏览器支持的编解码器和选项以及已收集的所有ICE候选者的信息。
4. setRemoteDescription
设置改变与远程连接关联的视频描述。该描述指定了连接远程终端的视频属性,包括媒体格式。通常在通过信令服务器从远程终端收到Answer答复后调用此方法。
因为在两个对等方就配置达成一致之前将交换描述,所以通过调用提交的描述setRemoteDescription()不会立即生效。相反,当前的连接配置将保持不变,直到协商完成。只有这样,商定的配置才会生效。
5. onaddstream
当执行addstream操作时,将发送此类事件。该事件在调用后立即发送setRemoteDescription(),并且不等待SDP协商的结果。
6. onicecandidate
每当本地ICE代理需要通过信令服务器将消息传递到远程终端时,就会触发事件。这使ICE代理可以与远程终端进行协商,而浏览器本身无需知道有关用于信令的技术的任何细节。只需实施此方法即可使用您选择的任何将ICE候选者发送到远程终端的消息传递技术。
实践代码
前端页面
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>WebRTC实践点对点通信</title>
<script src="../javascript/trace.js"></script>
<script src="../javascript/socket.io.js"></script>
<script src="../javascript/jquery-1.11.1.min.js"></script>
<script src="../javascript/jquerysocketio.js"></script>
<script src="../javascript/rtc.js"></script>
<style type="text/css">
video {
border: 1px solid black;
background-color: black;
max-width: 100%;
width: 100px;
}
</style>
<script>
var cfg = {
ip: '127.0.0.1',
port: 6100
}
$(function() {
$.net.connect(function(person) {
trace("在线人数:" + person);
if(person.length > 0) {
$("#personDiv").empty();
person.forEach(p => {
if(p.id != $.net.id) {
var html = '<input type="radio" value="' + p.id + '" name="g" id="' + p.id + '" /><label for="' + p.id + '">' + p.id + '</label>';
$("#personDiv").append(html);
}
});
}
});
$.net.receive(function(body) {
trace("收到:" + JSON.stringify(body));
});
rtc.init();
$("#StartBtn").click(function() {
rtc.call();
});
$("#personDiv").delegate("input[type='radio']", "click", function() {
rtc.curVideoFriendId=this.value;
});
})
</script>
</head>
<body>
<h1>WebRTC实践点对点通信</h1>
<video id="localVideo" autoplay playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
<input type="button" value="Start" id="StartBtn" />
<input type="button" value="Close" id="CloseBtn" />
<div id="personDiv"></div>
</body>
</html>
RTC代码
var rtc = (function() {
///通讯是否创建
var isChannelReady = false;
///房间是否创建
var isInitiator = false;
var isStarted = false;
var remoteStream;
///当前收到对方的房间号
var curReceiveRoomId = "";
var pc = new RTCPeerConnection(null);
pc.onicecandidate = function(event) {
trace('icecandidate event: ', event);
if (event.candidate) {
sendMessage({
type: 'candidate',
label: event.candidate.sdpMLineIndex,
id: event.candidate.sdpMid,
candidate: event.candidate.candidate
});
} else {
trace('End of candidates.');
}
};
pc.onaddstream = function(event) {
trace('Remote stream added.');
remoteStream = event.stream;
document.getElementById("remoteVideo").srcObject = remoteStream;
};
$.net.join(function() {
///通道已准备就绪
isChannelReady = true;
})
.call(doOffer)
.offer(doAnswer)
.answer(function(message) {
if (isStarted) {
pc.setRemoteDescription(new RTCSessionDescription(message));
}
})
.candidate(function(message) {
if (isStarted) {
pc.addIceCandidate(new RTCIceCandidate({
sdpMLineIndex: message.label,
candidate: message.candidate
}));
}
})
.bye(function(message) {
if (isStarted) {
trace('对方连接已断开。');
pc.close();
pc = null;
isStarted = false;
isInitiator = false;
}
});
function sendMessage(message) {
$.net.meeting(message);
}
function doCall() {
//var toid = $("input[name='g']:checked").val();
var toid = rtc.curVideoFriendId;
if (!toid) {
alert('暂无目标好友。');
return;
}
var cmd = {
body: 'got user media',
roomid: curReceiveRoomId ? curReceiveRoomId : $.net.id
};
$.net.send(cmd, toid, 'media');
trace('发送' + JSON.stringify(cmd));
}
function doOffer(msg) {
if (msg.roomid) {
curReceiveRoomId = msg.roomid;
trace('收到对方的房间编号 "' + curReceiveRoomId + '" !');
}
if (!isChannelReady) {
trace('网络通讯没有建立不能发offer');
return;
}
if (!isInitiator) {
trace('不是房间创建者不能发offer');
return;
}
trace('发送视频通话邀请函offer');
pc.createOffer(function(description) {
pc.setLocalDescription(description); //触发onicecandidate事件
trace('setLocalAndSendMessage sending message', description);
sendMessage(description);
}, trace);
isStarted = true;
}
function doAnswer(message) {
trace('Sending answer to peer.');
pc.setRemoteDescription(new RTCSessionDescription(message)); //触发onaddstream事件
pc.createAnswer().then(
function(description) {
pc.setLocalDescription(description); //触发onicecandidate事件
trace('setLocalAndSendMessage sending message', description);
sendMessage(description);
}, trace
);
}
return {
curVideoFriendId: '',
close: function() {
sendMessage('bye');
/*
var room = roomService.get();
if(room.id == $.net.id) {
trace('房间 "' + room.id + '"已销毁。');
roomService.delete();
}*/
curReceiveRoomId = "";
},
room: function() {
//var room = roomService.get();
if (curReceiveRoomId) {
$.net.meeting('join', curReceiveRoomId, function(r) {
trace('入进房间:', JSON.stringify(r));
isChannelReady = true;
});
} else {
//room = roomService.create($.net.id);
//room = roomService.create('testRoom');
$.net.meeting('create', $.net.id, function(r) {
trace('创建房间', JSON.stringify(r));
isInitiator = true;
});
}
},
video: function(ctrl) {
navigator.mediaDevices.getUserMedia({
audio: false,
video: true
}).then(stream => {
trace('本地视频已开启。');
if (stream) {
pc.addStream(stream)
document.getElementById(ctrl).srcObject = stream;
///本是视频就绪,向对方主播发送call通知
doCall();
}
});
},
call: function() {
if (!rtc.curVideoFriendId) {
alert('没有选择视频通话的目标好友!');
return;
}
rtc.room();
rtc.video("localVideo");
},
init: function() {
window.onbeforeunload = rtc.close;
$(document).delegate("#CloseBtn", "click", function() {
rtc.close();
});
}
}
})();
服务端(Nodejs)
var persons = [];
var getPersonSeq = function() {
var tempSeq = Math.floor(Math.random() * 5) + 1;
persons.forEach(p => {
if (p.seq == tempSeq) {
tempSeq = getPersonSeq();
}
});
return tempSeq;
}
io.sockets.on('connection', function(socket) {
if (persons.indexOf(socket.id) == -1) {
persons.push({
id: socket.id,
seq: getPersonSeq()
});
}
function trace() {
console.log(arguments);
}
console.log("用户 '" + socket.id + "' 连接成功!");
socket.emit('ready', socket.id, persons);
socket.broadcast.emit('change', persons);
socket.on('disconnect', function() {
trace('终端(' + socket.id + ')已断开。 ');
var tempPersons = [];
persons.forEach(e => {
if (e.id != socket.id) {
tempPersons.push(e);
}
});
persons = tempPersons;
//rooms.del(socket.id);
socket.broadcast.emit('change', persons);
});
socket.on('message', function(body) {
var d = new Date();
body.from = socket.id;
body.time = d.getHours() + ":" + d.getMinutes() + ":" + d.getSeconds();
socket.to(body.to).emit('message', body);
trace('终端(' + socket.id + "):message>", body);
});
socket.on('meeting', function(body) {
///向除自己外所有meeting终端广播消息
socket.broadcast.emit('meeting', body);
//sockets.in(room).emit('meeting', body);
trace('终端(' + socket.id + "):meeting>", body);
});
socket.on('create', function(room, callbackfunc) {
var clientsInRoom = io.sockets.adapter.rooms[room];
var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
var temproom = {
id: room,
count: numClients
};
socket.join(room);
//rooms.save(temproom);
if (typeof(callbackfunc) == 'function') {
callbackfunc(temproom);
}
});
socket.on('join', function(room, callbackfunc) {
var clientsInRoom = io.sockets.adapter.rooms[room];
var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;
var robj = {
id: room,
count: numClients
}
switch (numClients) {
case 0:
trace('终端(' + socket.id + ')进入的房间 ”' + room + '" 不存在!');
socket.emit('empty', room);
break;
case 1:
///向房间room中发送消息
io.sockets.in(room).emit('join', room);
socket.join(room);
trace('房间 ' + room + '中现在有' + numClients + '个终端!');
break;
case 2:
socket.emit('full', room);
trace('房间 ”' + room + '" 已满。');
break;
}
if (typeof(callbackfunc) == 'function') {
callbackfunc(robj);
}
});
socket.on('ipaddr', function() {
var ifaces = os.networkInterfaces();
for (var dev in ifaces) {
ifaces[dev].forEach(function(details) {
if (details.family === 'IPv4' && details.address !== '127.0.0.1') {
socket.emit('ipaddr', details.address);
}
});
}
});
});
运行结果
实践历程
1. WebRTC实践简介
2. WebRTC实践获取视频流
3. WebRTC实践传输视频流
4. WebRTC实践信令服务
5. WebRTC实践点对点通信
6. WebRTC实践视频聊天室
7. WebRTC实践总结