去年TSINGSEE青犀视频研发团队基于WEBRTC架构开了网页音视频通话平台EasyRTC,EasyRTC支持微信小程序、H5页面、APP、PC客户端等接入方式之间互通,快速从零开始搭建实时音视频通信;支持多人至百万人视频通话,满足语音视频社交。
如果大家对EasyRTC感兴趣,可以联系我们进行了解或试用。今年我们仍然没有停止对WEBRTC技术的探索。本文和大家分享一下通过webrtc实现局域网进行音视频连接的步骤。
1、首先要创建服务代码,来确保服务器启动
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
const io = new socket({
ioOptions: {
pingTimeout: 10000,
pingInterval: 5000,
}
});
const https = require("https");
const fs = require("fs");
// 创建一个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 => {
sock.on('join', data=>{
console.log("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);
}
sockS[data.account] = sock;
app._io.in(data.roomid).emit('joined', users[data.roomid], data.account, sock.id); // 发给房间内所有人
// sock.to(data.roomid).emit('joined',data.account);
});
});
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);
});
// 1 v 1
sock.on('apply', data=>{ // 转发申请
// console.log("apply:", data);
sockS[data.account].emit('apply', data);
});
sock.on('reply', data=>{ // 转发回复
console.log("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);
});
2、在进行音视频通信是,必修要本地端口或者https服务。
(1)网上下载OpenSSL软件
(2)创建后缀名(.Key和.cert)证书;
查看网址教程:https://www.cnblogs.com/tugenhua0707/p/10927722.html
(3)在js服务把证书添加进去
const options = {
key: fs.readFileSync("./server.key", "utf8"),
cert: fs.readFileSync("./server.cert", "utf8")
};
let server = https.createServer(options, app.callback()).listen(3002);
app._io.listen(server);
3、客户端代码采用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: {
join() {
if (!this.account) return;
this.isJoin = true;
window.sessionStorage.account = this.account;
socket.emit('join', {roomid: this.roomid, account: this.account});
},
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;
}
});
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;
console.log("发送端:", Date.now());
} 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;
console.log("接送端:", Date.now());
};
},
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();
if (this.account) {
this.join();
}
}
}
</script>
<style lang="scss" scoped>
.remote1{
100%;
height: 100%;
display: flex;
justify-content: flex-start;
}
.shade{
position: fixed;
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;
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;
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{
400px;
height: 300px;
margin-left: 20px;
background-color: #ddd;
}
}
</style>
说明
首先必修启动服务端建立连接,在通过呼叫对方id进行识别;建立PeerConnection视频连接,并把SDP的ICE候选者传递candidate给远程对等体。