前言
在上一篇博文 【复】基于 WebRTC 的音视频在线监考模块的设计与实现(上) 中,主要介绍了关于 WebRTC 的基本理论,那么这篇文章我们将进入实战阶段,通过 WebRTC 框架,去实现 P2P 通话,以及延伸到一对多的音视频通话,从而实现在线监考功能;
P2P 通话实现
媒体设备
在开发 Web 时,WebRTC 标准提供了 API,用于访问连接到计算机或智能手机的相机和麦克风,这些设备通常称为媒体设备,可以通过实现 MediaDevices
接口的 navigator.mediaDevices
对象使用 JavaScript 进行访问。通过该对象,我们可以枚举所有已连接的设备,侦听设备更改(连接或断开设备时),并打开设备以检索媒体流。
调用 getUserMedia()
将触发权限请求。如果用户接受许可,则通过包含一个视频和一个音轨的 MediaStream
来解决承诺。如果权限被拒绝,则抛出 PermissionDeniedError
。如果没有连接匹配的设备,则会抛出 NotFoundError
。
- 创建媒体流
async createMedia() {
let streamTep = null;
// 保存本地流到全局
streamTep = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
})
console.log("streamTep",streamTep)
return streamTep;
},
- 播放媒体流
<div style="float: left">
<video id="sucA" autoplay></video>
</div>
// 打开本地摄像头
async nativeMedia(){
const that = this;
that.localStream = await this.createMedia()
let video = document.querySelector('#sucA');
// 旧的浏览器可能没有srcObject
if ("srcObject" in video) {
video.srcObject = that.localStream;
} else {
video.src = window.URL.createObjectURL(that.localStream);
}
that.initPeer(); // 获取到媒体流后,调用函数初始化 RTCPeerConnection
},
- 媒体设备约束条件
// 设置视频窗口的范围
{
"video": {
"width": {
"min": 640,
"max": 1024
},
"height": {
"min": 480,
"max": 768
}
}
}
// 获取手机端前置摄像头
{ audio: true, video: { facingMode: "user" } }
// 后置摄像头
{ audio: true, video: { facingMode: { exact: "environment" } } }
// 具有带宽限制的WebRTC传输,可能需要较低的帧速率
{ video: { frameRate: { ideal: 10, max: 15 } } }
通讯连接
RTCPeerConnection 接口表示本地计算机和远程对等方之间的 WebRTC 连接。它提供了连接到远程对等方,维护和监视连接以及在不再需要连接时关闭连接的方法。
RTCPeerConnection 建立
- 本地流获取(上述内容)
- 全局参数初始化
window.RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
var iceServers = {
iceServers: [
{ url: "stun:stun.l.google.com:19302"},// 谷歌的公共服务
{
url: 'turn:numb.viagenie.ca',
credential: 'muazkh',
username: 'webrtc@live.com'
}
]
};
- 初始化两个模拟客户端
// 以下 pc1 和 pc2 分别代表两个模拟客户端的链接服务简写
// pc1: 代表 pc1->pc2 链接 pc2: 代表 pc2->pc1 链接
const that = this;
that.pc1 = new PeerConnection(iceServers);
that.pc2 = new PeerConnection(iceServers);
// 将全局视频流赋给 pc1 链接服务
that.pc1.addStream(this.localStream);
// 监听 ice 候选信息
that.pc1.onicecandidate = function(event) {
console.log("pc1 onicecandidate", event)
if (event.candidate) {
// 一般来说这个地方是通过第三方 (socket 后面会将网络端点对点) 发送给另一个客户端,但是现在本地演示直接将候选信息发送到 pc2 链接服务
that.pc2.addIceCandidate(event.candidate.toJSON());
}
};
// 监听远程视频 pc1 充当呼叫端,所以只要监听 pc2 有无视频流信息进来
that.pc2.onaddstream = (event) => {
console.log("pc2 onaddstream",event)
// 监听到流后将视频流赋给另一个 video 标签
let video = document.querySelector('#sucB');
video.srcObject = event.stream;
video.onloadedmetadata = function(e) {
console.log(e)
video.play();
};
};
onicecandidate
: 候选 ICE 描述了 WebRTC 能够与远程设备进行通信所需的协议和路由。在启动 WebRTC 对等连接时,通常在连接的每一端都建议多个候选对象,直到他们相互同意描述他们认为最好的连接的候选对象为止。
- 呼叫端模拟呼叫(pc1)和应答端模拟应答(pc2)
async createOffer() {
const that = this;
// 创建 offer
let offer_tep = await that.pc1.createOffer(this.offerOption);
console.log("offer_tep", offer_tep)
// 设置本地描述
await that.pc1.setLocalDescription(offer_tep)
//接收端设置远程 offer 描述
await that.pc2.setRemoteDescription(offer_tep)
// 接收端创建 answer
let answer = await that.pc2.createAnswer();
// 接收端设置本地 answer 描述
await that.pc2.setLocalDescription(answer);
// 发送端设置远程 answer 描述
await that.pc1.setRemoteDescription(answer);
},
// 呼叫
async call() {
const that = this;
//创建 offer 并保存本地描述
await that.createOffer()
},
为何呼叫会有这么麻烦的步骤呢?这就又涉及到 WebRTC 的会话了,具体看下面一条:
“当用户 (上述pc1) 向另一个用户(上述pc2)发起 WebRTC 呼叫时,会创建一个特殊的描述,称为 offer。此描述包括有关呼叫者为呼叫建议的配置的所有信息。然后,接收者用一个答案来回应,这是他们通话结束的描述。以此方式,两个设备彼此共享为了交换媒体数据所需的信息。这种交换是使用交互式连接建立(ICE)处理的,该协议允许两个设备使用中介程序交换要约和答复,即使两个设备之间都被网络地址转换(NAT)隔开。然后,每个对等方都保留两个描述:本地描述(描述自己)和远程描述(描述呼叫的另一端)”
上面的话简单来说就是 A 呼叫 B,A 创建 offer,在本地保留 offer,然后发送给 B,B 创建 answer,之后本地保留 answer,再将 answer 发送给 A,A 拿到后将 B 的 answer 设置为本地的远程描述。
在线监考
通过刚才的 P2P 学习,想必已经了解了双方之间是如何建立通讯的,那么基于 WebRTC 的在线监考原理也是如此,老师与同学们建立通讯即可,即一对多的关系,这样就能实现在线监考了;
这里使用的是 vue + node 的实现形式,可以根据自己的需要进行改进;
<script>
import * as config from '../../configure';
navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia;
window.RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate || window.webkitRTCIceCandidate;
window.RTCSessionDescription =
window.RTCSessionDescription || window.mozRTCSessionDescription || window.webkitRTCSessionDescription;
const socket = io.connect(config.API_ROOT);
const configuration = {
iceServers: [config.DEFAULT_ICE_SERVER],
};
let localStream, peerConn;
let connectedUser = null;
export default {
data() {
return {
user_name: '',
show: true,
users: '',
call_username: '',
remote_video: '',
accept_video: false,
peersList: [],
};
},
mounted() {
socket.on(
'message',
function(data) {
console.log(data);
switch (data.event) {
case 'show':
this.users = data.allUsers;
break;
case 'join':
this.handleLogin(data);
break;
case 'call':
this.handleCall(data);
break;
case 'accept':
this.handleAccept(data);
break;
case 'offer':
this.handleOffer(data);
break;
case 'candidate':
this.handleCandidate(data);
break;
case 'msg':
this.handleMsg(data);
break;
case 'answer':
this.handleAnswer(data);
break;
case 'leave':
this.handleLeave();
break;
default:
break;
}
}.bind(this)
);
},
methods: {
submit() {
if (this.user_name !== '') {
this.send({
event: 'join',
name: this.user_name,
});
}
},
send(message) {
if (connectedUser !== null) {
message.connectedUser = connectedUser;
}
socket.send(JSON.stringify(message));
},
handleLogin(data) {
if (data.success === false) {
alert('Ooops...please try a different username');
} else {
this.show = false;
this.users = data.allUsers;
this.initCreate();
}
},
addVideoURL(elementId, stream) {
var video = document.getElementById(elementId);
// Older brower may have no srcObject
if ('srcObject' in video) {
video.srcObject = stream;
} else {
// 防止在新的浏览器里使用它,应为它已经不再支持了
video.src = window.URL.createObjectURL(stream);
}
},
initCreate() {
const self = this;
navigator.mediaDevices
.getUserMedia({ audio: true, video: true })
.then(function(stream) {
var video = document.getElementById('localVideo');
self.addVideoURL('localVideo', stream);
video.muted = true;
localStream = stream;
})
.catch(function(err) {
console.log(err.name + ': ' + err.message);
});
},
call() {
if (this.call_username.length > 0) {
if (this.users[this.call_username] === true) {
connectedUser = this.call_username;
this.createConnection(connectedUser);
this.send({
event: 'call',
});
} else {
alert('The current user is calling, try another');
}
} else {
alert('Ooops...this username cannot be empty, please try again');
}
},
createConnection(username) {
peerConn = new RTCPeerConnection(configuration);
peerConn.addStream(localStream);
peerConn.onaddstream = e => {
this.addVideoURL('remoteVideo'+username, e.stream);
};
peerConn.onicecandidate = event => {
setTimeout(() => {
if (event.candidate) {
this.send({
event: 'candidate',
candidate: event.candidate,
});
}
});
};
},
handleCall(data) {
this.accept_video = true;
connectedUser = data.name;
},
reject() {
this.send({
event: 'accept',
accept: false,
});
this.accept_video = false;
},
accept() {
this.send({
event: 'accept',
accept: true,
});
this.accept_video = false;
},
handleAccept(data) {
if (data.accept) {
// Create an offer
peerConn.createOffer(
offer => {
this.send({
event: 'offer',
offer: offer,
});
peerConn.setLocalDescription(offer);
},
error => {
alert('Error when creating an offer');
}
);
} else {
alert('对方已拒绝');
}
},
handleOffer(data) {
connectedUser = data.name;
this.createConnection(connectedUser);
peerConn.setRemoteDescription(new RTCSessionDescription(data.offer));
// Create an answer to an offer
peerConn.createAnswer(
answer => {
peerConn.setLocalDescription(answer);
this.send({
event: 'answer',
answer: answer,
});
},
error => {
alert('Error when creating an answer');
}
);
this.peersList.push(connectedUser)
},
handleMsg(data) {
console.log(data.message);
},
handleAnswer(data) {
peerConn.setRemoteDescription(new RTCSessionDescription(data.answer));
},
handleCandidate(data) {
// ClientB 通过 PeerConnection 的 AddIceCandidate 方法保存起来
peerConn.addIceCandidate(new RTCIceCandidate(data.candidate));
},
hangUp() {
this.send({
event: 'leave',
});
this.handleLeave();
},
handleLeave() {
alert('通话已结束');
connectedUser = null;
this.remote_video = '';
peerConn.close();
peerConn.onicecandidate = null;
peerConn.onaddstream = null;
if (peerConn.signalingState === 'closed') {
this.initCreate();
}
},
closePreview() {
this.accept_video = false;
},
},
};
</script>
var express = require('express');
var app = express();
var http = require('http');
var fs = require('fs');
var IO = require('socket.io');
var { APT_HOST, API_PORT } = require('./configure');
app.use(express.static('dist'));
var server = http.createServer(app);
console.log('The HTTPS server is up and running');
var io = IO(server);
console.log('Socket Secure server is up and running.');
server.listen(API_PORT, APT_HOST, function(){
console.log('Access Address: http://%s:%s', APT_HOST, API_PORT);
});
// All joined users
var allUsers = {};
// All joined sockets
var allSockets = {};
io.on('connect', function(socket) {
var user = ''; // current joined user
socket.on('message', function(data) {
var data = JSON.parse(data);
switch (data.event) {
// When has new user join in
case 'join':
user = data.name;
console.log('User joined', data.name);
// Save users info
allUsers[user] = true; // 'true' means has not call, 'false' means calling
allSockets[user] = socket;
socket.name = user;
showUserInfo(allUsers);
sendTo(socket, {
event: 'join',
allUsers: allUsers,
success: true,
});
break;
case 'call':
var conn = allSockets[data.connectedUser];
sendTo(conn, {
event: 'call',
name: socket.name,
});
break;
case 'offer':
// i.e. UserA wants to call UserB
console.log('Sending offer to: ', data.connectedUser);
//if UserB exists then send him offer details
var conn = allSockets[data.connectedUser];
// allUsers[user] = false;
allUsers[user] = true;
if (conn != null) {
showUserInfo(allUsers);
// Setting that UserA connected with UserB
socket.otherName = data.connectedUser;
sendTo(conn, {
event: 'offer',
offer: data.offer,
name: socket.name,
});
} else {
sendTo(socket, {
event: 'msg',
message: 'Not found this name',
});
}
break;
case 'accept':
var conn = allSockets[data.connectedUser];
if (conn != null) {
if (data.accept) {
sendTo(conn, {
event: 'accept',
accept: true,
});
} else {
allUsers[data.connectedUser] = true;
sendTo(conn, {
event: 'accept',
accept: false,
});
}
}
break;
case 'answer':
console.log('Sending answer to: ', data.connectedUser);
// i.e. UserB answers UserA
var conn = allSockets[data.connectedUser];
// allUsers[user] = false;
allUsers[user] = true;
if (conn != null) {
showUserInfo(allUsers);
socket.otherName = data.connectedUser;
sendTo(conn, {
event: 'answer',
answer: data.answer,
});
}
break;
case 'candidate':
console.log('Sending candidate to:', data.connectedUser);
var conn1 = allSockets[data.connectedUser];
var conn2 = allSockets[socket.otherName];
if (conn1 != null) {
sendTo(conn1, {
event: 'candidate',
candidate: data.candidate,
});
} else {
sendTo(conn2, {
event: 'candidate',
candidate: data.candidate,
});
}
break;
case 'leave':
console.log('Disconnecting from', data.connectedUser);
var conn = allSockets[data.connectedUser];
allUsers[socket.name] = true;
allUsers[data.connectedUser] = true;
socket.otherName = null;
// Notify the other user so he can disconnect his peer connection
if (conn != null) {
showUserInfo(allUsers);
sendTo(conn, {
event: 'leave',
});
}
break;
}
});
socket.on('disconnect', function() {
if (socket.name) {
delete allUsers[socket.name];
delete allSockets[socket.name];
showUserInfo(allUsers);
if (socket.otherName) {
console.log('Disconnecting from ', socket.otherName);
var conn = allSockets[socket.otherName];
allUsers[socket.otherName] = true;
socket.otherName = null;
if (conn != null) {
sendTo(conn, {
type: 'leave',
});
}
}
}
});
});
function showUserInfo(allUsers) {
sendTo(io, {
event: 'show',
allUsers: allUsers,
});
}
function sendTo(connection, message) {
connection.send(message);
}
界面自己调整,这里就是为了方便展示;
注意,如果浏览器无法获取到摄像头,并报错Cannot read properties of undefined (reading 'getUserMedia')
,是因为浏览器有安全设置,只需要进行如下操作即可开放摄像头权限:
chrome://flags/#unsafely-treat-insecure-origin-as-secure
不是用谷歌的小伙伴可以自行替换前缀,比如 Edge 浏览器:
edge://flags/#unsafely-treat-insecure-origin-as-secure
最后在旁边的空白处点一下,底部就会出现如下图所示:
点一下 Relauch 就可以了;
后记
总的来说,WebRTC 还是超赞的,node.js 也是,记录每一个脚印!文章来源:https://www.toymoban.com/news/detail-430255.html
参考:文章来源地址https://www.toymoban.com/news/detail-430255.html
- webrtc实现群聊系列文章(一)本地模拟视频通话
- Introduction to WebRTC protocols
- vue+node(socket.io)+webRTC实现一对一通话测试
- 使用浏览器访问远程服务,调用本地摄像头录制音视频报错
到了这里,关于【复】基于 WebRTC 的音视频在线监考模块的设计与实现(下)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!