0


webRTC实时通信demo

参考文档:

https://www.jianshu.com/p/f439ce5cc0be
https://www.w3cschool.cn/socket

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

#mermaid-svg-0KZaDQ5DBl28zjmZ {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ .error-icon{fill:#552222;}#mermaid-svg-0KZaDQ5DBl28zjmZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0KZaDQ5DBl28zjmZ .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-0KZaDQ5DBl28zjmZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0KZaDQ5DBl28zjmZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0KZaDQ5DBl28zjmZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0KZaDQ5DBl28zjmZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0KZaDQ5DBl28zjmZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0KZaDQ5DBl28zjmZ .marker.cross{stroke:#333333;}#mermaid-svg-0KZaDQ5DBl28zjmZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0KZaDQ5DBl28zjmZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0KZaDQ5DBl28zjmZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0KZaDQ5DBl28zjmZ .actor-line{stroke:grey;}#mermaid-svg-0KZaDQ5DBl28zjmZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ .sequenceNumber{fill:white;}#mermaid-svg-0KZaDQ5DBl28zjmZ #sequencenumber{fill:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ .messageText{fill:#333;stroke:#333;}#mermaid-svg-0KZaDQ5DBl28zjmZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0KZaDQ5DBl28zjmZ .labelText,#mermaid-svg-0KZaDQ5DBl28zjmZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0KZaDQ5DBl28zjmZ .loopText,#mermaid-svg-0KZaDQ5DBl28zjmZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0KZaDQ5DBl28zjmZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0KZaDQ5DBl28zjmZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0KZaDQ5DBl28zjmZ .noteText,#mermaid-svg-0KZaDQ5DBl28zjmZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0KZaDQ5DBl28zjmZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0KZaDQ5DBl28zjmZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0KZaDQ5DBl28zjmZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0KZaDQ5DBl28zjmZ .actorPopupMenu{position:absolute;}#mermaid-svg-0KZaDQ5DBl28zjmZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-0KZaDQ5DBl28zjmZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0KZaDQ5DBl28zjmZ .actor-man circle,#mermaid-svg-0KZaDQ5DBl28zjmZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0KZaDQ5DBl28zjmZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}
网页A

   服务端 
 
   网页B 
 
获取本地视频 

同步网页A的信令及ice信息 

同步网页B的信令及ice信息 

返回网页B的信令及ice信息 

返回网页A的信令及ice信息 

推送网页A的视频流给网页B(视频流不经过服务端) 

结束推送 

  网页A 

  服务端 

  网页B 

demo运行效果

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

打开网页A获取本地视频:
在这里插入图片描述
点击呼叫交换网页的信令和ice信息并开始视频流推送:
在这里插入图片描述
点击挂断退出视频流推送:
在这里插入图片描述

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

准备条件

  1. 网页A所在PC需要准备好外接USB摄像头;
  2. 启动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/socketvar 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]必定返回undefinedvar 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
--><!DOCTYPEhtml><html><head><metacharset="utf-8"><title>userA</title></head><body><divclass="container"><h1>userA</h1><hr><divclass="video_container"align="center"><videoid="local_video"controls="controls"autoplaymuted></video></div><hr><buttonid="startButton">获取本地视频</button><buttonid="callButton">呼叫</button><buttonid="hangupButton">挂断</button><!-- <span id="data"></span> --><scriptsrc="/socket.io/socket.io.js"></script><!-- <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> --><scriptsrc="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);functiongotDevices(infos){// document.getElementById("data").innerHTML = JSON.stringify(infos);}functionstartAction(){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}`);}}functioncallAction(){
    callButton.disabled =true;
    hangupButton.disabled =false;// pc = new RTCPeerConnection(config);// 创建一个本地到远端的webRTC对象
    pc =newRTCPeerConnection();// 获取媒体流中的轨道信息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);}});// 当信令和补充信息双方同步完成后即可开始会商}functionhangupAction(){
    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(newRTCSessionDescription(message));setTimeout(function(){
            console.log(`remote answer is ${JSON.stringify(pc.remoteDescription)}`);},1000);}});

socket.on('ice',function(message){if(pc !=='undefined'){
        pc.addIceCandidate(newRTCIceCandidate(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
--><!DOCTYPEhtml><html><head><metacharset="utf-8"><title>对方的视频</title><metaname="viewport"content="width=device-width, initial-scale=1"></head><body><divclass="container"><h1>对方的视频</h1><hr><divclass="video_container"align="center"><videoid="remote_video"controlsautoplay></video></div><hr><scriptsrc="/socket.io/socket.io.js"></script><!-- <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script> --><scriptsrc="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 =newRTCPeerConnection();// 在webRTC对象中记录源端offer信息(注意记录本端信息调用的是setLocalDescription,记录对端信息调用的是setRemoteDescription)
    pc.setRemoteDescription(newRTCSessionDescription(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(newRTCIceCandidate(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页面推送视频的最后一帧。

注意点

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

备注

启动服务端成功,页面访问时服务端报错:
服务端报错
原因:这是由于node版本过低导致的,出现问题的node版本是8.11.1,切换为20.10.0后问题修复。

标签: webrtc javascript node

本文转载自: https://blog.csdn.net/lqlqlq007/article/details/123817684
版权归原作者 讨厌走开啦 所有, 如有侵权,请联系我们删除。

“webRTC实时通信demo”的评论:

还没有评论