webRTC实时通信demo

讨厌走开啦 2024-06-27 09:33:01 阅读 53

参考文档:

https://www.jianshu.com/p/f439ce5cc0be

https://www.w3cschool.cn/socket

demo流程示意图(用户A向用户B推送视频):

demo运行效果

由于CSDN限制了上传gif文件的大小,故整个操作流程拆分成以下几个步骤:

打开网页A获取本地视频:

在这里插入图片描述

点击呼叫交换网页的信令和ice信息并开始视频流推送:

在这里插入图片描述

点击挂断退出视频流推送:

在这里插入图片描述

为了方便展示完整的交互流程,网页A和网页B都是在同一台PC上打开,实际上演示效果和局域网内用两台PC分开打开网页A和网页B是一样的。

准备条件

网页A所在PC需要准备好外接USB摄像头;启动https server所需的私钥和证书(可以用openSSL工具生成,如启动的是http server,则不需要)。

demo源码

创建前端工程

新建一个文件夹,然后在文件夹内执行以下命令创建前端工程:

npm init

下载依赖

参考以下package.json内容下载依赖库(参考文档中使用的socket.io为2.x版本,demo中的部分代码针对4.x版本有做适配调整,想要在本地一次运行成功,所有的依赖库版本务必与demo保持一致):

{

"name": "webrtc",

"version": "1.0.0",

"description": "",

"main": "index.js",

"scripts": {

"test": "echo \"Error: no test specified\" && exit 1"

},

"author": "liqing",

"license": "ISC",

"dependencies": {

"express": "^4.17.3",

"socket.io": "^4.4.1"

}

}

服务端

在项目根目录创建index.js(本地运行时注意修改私钥、证书的地址以及server IP),代码如下:

/*

* @Author: liqing

* @Date: 2022-03-29 11:18:39

* @LastEditors: liqing

* @LastEditTime: 2023-08-02 14:51:25

* @Description: description

* DEMO参考文档:https://www.jianshu.com/p/f439ce5cc0be

* 注意socket.io的版本:

* 如果使用4.x版本,io.sockets.adapter.rooms[room]无法获得房间信息(3.x版本以后rooms返回的是Set,不再是对象了)

* 如果使用2.0.3版本,navigator.mediaDevices.getUserMedia不可用(未验证需要如何修改)

*/

'use strict'

var express = require('express');

var fs = require('fs');

var app = express();

const options = {

key: fs.readFileSync('D:/my/cakey.pem'),

cert: fs.readFileSync('D:/my/cacert.pem')

};

var http = require('https').createServer(options, app);

// var http = require('http').createServer(app);

// socket.io API地址:https://www.w3cschool.cn/socket

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

// 静态资源代理

app.use('/css', express.static('css'));

app.use('/js', express.static('js'));

app.use('/img', express.static('img'));

app.use('/module', express.static('module'));

// 路由配置

// app.get('/', function (request, response) {

// response.sendFile(__dirname + '/index.html');

// });

app.get('/userA', function (request, response) {

response.sendFile(__dirname + "/userA.html")

});

app.get('/userB', function (request, response) {

response.sendFile(__dirname + "/userB.html")

});

app.get('/userC', function (request, response) {

response.sendFile(__dirname + "/userC.html")

});

app.get('/rtsp', function (request, response) {

response.sendFile(__dirname + "/index.html")

});

io.on('connection', function (socket) {

console.log(`有用户加入进来 and socket.id is ${ socket.id}`);

socket.on('signal', function (message) {

socket.to('room').emit('signal', message);

});

socket.on('ice', function (message) {

socket.to('room').emit('ice', message);

});

socket.on('create or join', function (room) {

// 当前使用的socket.io版本为4.4.1,原代码中io.sockets.adapter.rooms返回的已经不是对象,而是一个Set,因此原来的io.sockets.adapter.rooms[room]必定返回undefined

var clientsInRoom = io.sockets.adapter.rooms.get(room);

if (typeof clientsInRoom === "undefined") {

socket.join(room);

socket.emit('create', room, socket.id);

console.log('caller joined');

} else {

socket.join(room);

socket.to(room).emit('call');

console.log('callee joined');

}

});

});

/**

* 注意:如果定义的是http server,则在访问页面时会禁止页面调用摄像头/麦克风设备

* 规避方案:访问chrome://flags/,找到Insecure origins treated as secure配置项,把http://192.168.0.106:8080加入例外清单

* 本地运行时需要把下面的IP替换成本地的IP

*/

var server = http.listen(8080, '192.168.0.106', function () {

var host = server.address().address;

var port = server.address().port;

console.log(`listening on:http://${ host}:${ port}`);

});

/**

* 服务端代码不涉及任何webRTC内容,socket.io同步的消息不涉及音视频流

*/

https server启动所需的私钥(cakey.pem),可以保存在本地任意路径,但要注意同步修改index.js中的引用地址:

-----BEGIN RSA PRIVATE KEY-----

MIICXAIBAAKBgQCt4/3uQFLgyOGa0lFD8Y6QiVALVOwj1dV0ScMwtXskw0YvBqDk

tvW/xHFftmcqHj0/J8rBTcBXnQKPW/mAedE1jObkpUdv5h0VPI/dJ/uuFm/CoZr0

cKFwzY3hOPfNxXj/1wu7RA+eEbZXy1QaGETAb4reIp94gwc500Uvf0yzSwIDAQAB

AoGBAI9RrRW0AFryVjdjhsUoD2eDNOzSBnqWoIJi1TSNLzyikXLq1KsNPMjcYNER

JkApgjNOWacurQvJBbYgiShhvpI2bvnm12cq06Yh7NeWGwlejNXUV7PpvOptPUXD

An1hCyxdBp0eKDkh+ygbnPPsJQPes8sQvhJZ0TokgivEDKtRAkEA5KllwmzABQ8C

PlCQpEcU/Ukp4WNGsd5dBzMgxV5yHqvS4oSOgr4mwl78kLFRb4aS0KqHl7q3ztmp

qOmlQHJjWQJBAMKuOdt4Aec7N6eVD6MGfjfbRW5RVjN/5ScByvKzIkc/UC/nVRMT

kCS/JQQPpVcrD8mKzohiwTARizptb04660MCQBGEvOwZYtjAXp6hk4NSgtQo79F5

xqfH7n6ntyIH61xYM67xEu4HXXbUyirXuvJ9b/AWsI66Wmy5llr/k46NdPkCQBdj

GL49x3TAz2nJZWx/PjB1nfyntsRPC/dIptnLHUYT3A01LCozgnB3qfm363PyT141

16PYwT6GDQTC2sk6GMMCQERslIy4tmWDq4P+Nf5GYV8h3ZaD0OA6GhbdfrozxhyI

KC7GI/hF8XaTAWM8U0Lw/VFVNS3C2WzuAfPFbmoAUI0=

-----END RSA PRIVATE KEY-----

https server启动所需的证书(cacert.pem),可以保存在本地任意路径,但要注意同步修改index.js中的引用地址:

-----BEGIN CERTIFICATE-----

MIICZjCCAc+gAwIBAgIUCn88IxDVmvZKqgVaCVCPioC7DccwDQYJKoZIhvcNAQEL

BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM

GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjA3MDUwOTIyNTBaFw0yMjA4

MDQwOTIyNTBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw

HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwgZ8wDQYJKoZIhvcNAQEB

BQADgY0AMIGJAoGBAK3j/e5AUuDI4ZrSUUPxjpCJUAtU7CPV1XRJwzC1eyTDRi8G

oOS29b/EcV+2ZyoePT8nysFNwFedAo9b+YB50TWM5uSlR2/mHRU8j90n+64Wb8Kh

mvRwoXDNjeE4983FeP/XC7tED54RtlfLVBoYRMBvit4in3iDBznTRS9/TLNLAgMB

AAGjUzBRMB0GA1UdDgQWBBTzVCK2pDw/w/OfmtAQQHXCvv9NxDAfBgNVHSMEGDAW

gBTzVCK2pDw/w/OfmtAQQHXCvv9NxDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3

DQEBCwUAA4GBAJ7jf6ZTGXy5UWgN4nsfg3R/MA/FWbacatUwLrHH5U/vP6oxFY5a

4q7Cth4ayRagU7jF2kz6zZeEL0M+6b9Ysio9DquEbYnhUAnJBRm8l51wHkH5/fwQ

GYoKQlUx8R2vM84lHn/FPZazKOuIoaxSLGwwubn5BnW6N4W+HMbtRNa8

-----END CERTIFICATE-----

客户端

在项目根目录创建userA.html,代码如下:

<!--

* @Author: liqing

* @Date: 2022-03-29 11:13:04

* @LastEditors: liqing

* @LastEditTime: 2023-06-08 10:42:57

* @Description: description

-->

<!DOCTYPE html>

<html>

<head>

<meta charset="utf-8">

<title>userA</title>

</head>

<body>

<div class="container">

<h1>userA</h1>

<hr>

<div class="video_container" align="center">

<video id="local_video" controls="controls" autoplay muted></video>

</div>

<hr>

<button id="startButton">获取本地视频</button>

<button id="callButton">呼叫</button>

<button id="hangupButton">挂断</button>

<!-- <span id="data"></span> -->

<script src="/socket.io/socket.io.js"></script>

<!-- <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> -->

<script src="js/userA.js"></script>

</div>

</body>

</html>

在项目根目录创建js文件夹,在文件夹内创建userA.js,代码如下:

/*

* @Author: liqing

* @Date: 2022-03-29 11:17:08

* @LastEditors: liqing

* @LastEditTime: 2023-06-08 10:21:12

* @Description: description

*/

'use strict'

var localVideo = document.getElementById('local_video');

var startButton = document.getElementById('startButton');

var callButton = document.getElementById('callButton');

var hangupButton = document.getElementById('hangupButton');

var pc;

var localStream;

var socket = io.connect();

var config = {

'iceServers': [{

'urls': 'stun:stun.l.google.com:19302'

}]

};

const offerOptions = {

offerToReceiveVideo: 1,

offerToReceiveAudio: 1

};

callButton.disabled = false;

hangupButton.disabled = true;

startButton.addEventListener('click', startAction);

callButton.addEventListener('click', callAction);

hangupButton.addEventListener('click', hangupAction);

function gotDevices(infos) {

// document.getElementById("data").innerHTML = JSON.stringify(infos);

}

function startAction() {

try {

// 测试获取设备后置摄像头

navigator.mediaDevices.enumerateDevices().then(gotDevices);

// 关于navigator.mediaDevices.getUserMedia的定义可以参考https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices/getUserMedia

navigator.mediaDevices.getUserMedia({ video: true, audio: false }).then(function (mediastream) {

// 把音视频流放入变量localStream以及localVideo这个dom中

localStream = mediastream;

localVideo.srcObject = mediastream;

startButton.disabled = true;

}).catch(function (e) {

// 如果获取本地音视频时无外接设备(摄像头/麦克风)则会提示exception is NotFoundError: Requested device not found

console.log(`exception is ${ e}`);

alert(`exception is ${ e}`);

});

} catch (e) {

alert(`startAction exception is ${ e}`);

}

}

function callAction() {

callButton.disabled = true;

hangupButton.disabled = false;

// pc = new RTCPeerConnection(config);

// 创建一个本地到远端的webRTC对象

pc = new RTCPeerConnection();

// 获取媒体流中的轨道信息

let tracks = localStream.getTracks();

// 向上面生成的webRTC对象注入轨道信息

tracks.forEach(track => pc.addTrack(track, localStream));

// 作为源端创建offer对象(包含源端的媒体信息和编解码信息)

pc.createOffer(offerOptions).then(function (offer) {

// 在webRTC对象中记录offer信息(注意记录本端信息调用的是setLocalDescription,记录对端信息调用的是setRemoteDescription)

console.log(`offer is ${ JSON.stringify(offer)}`);

pc.setLocalDescription(offer);

// 同步offer信息给目的端(offer对象中的SDP参数含义可以参考https://blog.csdn.net/m370809968/article/details/88195181,SDP即为信令)

socket.emit('signal', offer);

});

// 当webRTC对象调用setLocalDescription方法时会抛出icecandidate事件(即触发以下监听的回调)

// 问题:为什么调用setLocalDescription方法会抛出icecandidate事件两次(两次信息不完全相同,如端口)

pc.addEventListener('icecandidate', function (event) {

var iceCandidate = event.candidate;

console.log(`iceCandidate is ${ JSON.stringify(iceCandidate)}`);

if (iceCandidate) {

// 同步补充描述信息给目的端(通过SDP协商结果进行信息交换),描述信息包括协议、IP、端口、优先级等等信息

// 问题:为什么这些描述信息不可以放在信令中

socket.emit('ice', iceCandidate);

}

});

// 当信令和补充信息双方同步完成后即可开始会商

}

function hangupAction() {

localStream.getTracks().forEach(track => track.stop());

pc.close();

pc = null;

hangupButton.disabled = true;

callButton.disabled = true;

startButton.disabled = false;

}

socket.on('create', function (room, id) {

console.log('userA创建聊天房间');

console.log(room + id);

});

socket.on('call', function () {

console.log('enter call');

callButton.disabled = false;

});

// 监听目的端同步的offer信息

socket.on('signal', function (message) {

if (pc !== 'undefined') {

// 在webRTC对象中记录目的端offer信息(注意记录本端信息调用的是setLocalDescription,记录对端信息调用的是setRemoteDescription)

pc.setRemoteDescription(new RTCSessionDescription(message));

setTimeout(function () {

console.log(`remote answer is ${ JSON.stringify(pc.remoteDescription)}`);

}, 1000);

}

});

socket.on('ice', function (message) {

if (pc !== 'undefined') {

pc.addIceCandidate(new RTCIceCandidate(message));

console.log('become candidate');

}

});

socket.emit('create or join', 'room');

在项目根目录创建userB.html,代码如下:

<!--

* @Author: liqing

* @Date: 2022-03-29 11:17:35

* @LastEditors: liqing

* @LastEditTime: 2022-07-29 11:45:19

* @Description: description

-->

<!DOCTYPE html>

<html>

<head>

<meta charset="utf-8">

<title>对方的视频</title>

<meta name="viewport" content="width=device-width, initial-scale=1">

</head>

<body>

<div class="container">

<h1>对方的视频</h1>

<hr>

<div class="video_container" align="center">

<video id="remote_video" controls autoplay></video>

</div>

<hr>

<script src="/socket.io/socket.io.js"></script>

<!-- <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> -->

<script src="js/userB.js"></script>

</div>

</body>

</html>

在js文件夹内创建userB.js,代码如下:

/*

* @Author: liqing

* @Date: 2022-03-29 11:17:53

* @LastEditors: liqing

* @LastEditTime: 2022-07-06 15:45:34

* @Description: description

*/

'use strict'

var remoteVideo = document.getElementById('remote_video');

var socket = io.connect();

var config = {

'iceServers': [{

'urls': 'stun:stun.l.google.com:19302'

}]

};

var pc;

socket.emit('create or join', 'room');

socket.on('join', function (room, id) {

console.log('userB加入房间');

});

// 监听源端同步的offer信息

socket.on('signal', function (message) {

console.log(`enter signal userB`);

// pc = new RTCPeerConnection(config);

// 创建一个本地到远端的webRTC对象,因为目的端是被动接收方,故在源端同步消息后才创建

pc = new RTCPeerConnection();

// 在webRTC对象中记录源端offer信息(注意记录本端信息调用的是setLocalDescription,记录对端信息调用的是setRemoteDescription)

pc.setRemoteDescription(new RTCSessionDescription(message));

// 作为目的端创建offer对象(包含目的端的媒体信息和编解码信息)

pc.createAnswer().then(function (answer) {

// 在webRTC对象中记录目的端offer信息(注意记录本端信息调用的是setLocalDescription,记录对端信息调用的是setRemoteDescription)

pc.setLocalDescription(answer);

// 同步offer信息给源端

socket.emit('signal', answer);

});

pc.addEventListener('icecandidate', function (event) {

var iceCandidate = event.candidate;

if (iceCandidate) {

console.log(`iceCandidate is ${ JSON.stringify(iceCandidate)}`);

socket.emit('ice', iceCandidate);

}

});

pc.addEventListener('addstream', function (event) {

remoteVideo.srcObject = event.stream;

});

});

socket.on('ice', function (message) {

console.log(`get ice message`);

pc.addIceCandidate(new RTCIceCandidate(message));

});

运行项目

在根目录下执行以下命令启动服务端:

node index.js

服务端运行成功如下图:

服务端运行成功截图

在浏览器(chrome)中分别打开以下两个地址模拟用户A访问和用户B访问(注意本地运行时需要切换为本机IP):

https://192.168.0.106:8080/userA

https://192.168.0.106:8080/userB

在userA页面点击获取本地视频按钮,此时如果浏览器是初次调用摄像头设备,则会有如下安全提示:

在这里插入图片描述

点击允许后userA页面就可以在网页中获取到自己的视频:

在这里插入图片描述

然后在userA页面点击呼叫,在userB页面就可以播放userA的视频:

在这里插入图片描述

在userA页面点击挂断即可终止视频推送,此时userB页面会停留在userA页面推送视频的最后一帧。

注意点

服务端存在的意义仅仅是帮助两个客户端完成信令及ice信息的交换:当网页A和网页B开始视频流推送时即使停掉nodejs服务,也不会影响视频通信;demo只能让局域网内的两台PC完成视频通信,如果希望在公网的两台PC可以视频通信,则需要配置iceServers(demo中有相关代码,但在构造RTCPeerConnection对象时未使用相应的配置)。

备注

启动服务端成功,页面访问时服务端报错:

服务端报错

原因:这是由于node版本过低导致的,出现问题的node版本是8.11.1,切换为20.10.0后问题修复。



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。