互联网实时通信平台,html5标准之一,使用简单的API就可以实现音频通信。
iSAC/ iLBC Codec
音视频编解码器
NetEQ for voice
声音增强
Ecoh Candeler / Noise Reduction
消除回声,减少噪音
VP8 Codec
编解码器
Video jitter buffer
数据包缓冲区
image enhancements
图像增强
首先思考的问题:两个不同网络环境的浏览器,要实现点对点的实时音视频对话,难点在哪里?
也就是要了解对方支持的媒体格式(编码格式),用什么格式编的码,就需要用相同的格式解码回来。
所以在传输之前,要找双方支持的媒体格式,然后使用都支持的格式来进行视频传输
通信双方交换彼此的SDP(Session Description Protocol)信息,以此来了解双方支持的媒体类型,这个过程叫做媒体协商
通信之前要了解双方的网络情况,确定大致的带宽,来决定通信的网络质量
NAT 网络地址转换
如果通信的双方都直接有一个公网IP,那么就可以直接进行通信
但是大多数电脑都不会独占一个公网IP(公网IP太少了),所以会将整个局域网内,为每个计算机分配一个私网IP(在这个局域网内唯一即可),这样局域网内部的计算机可以相互通信,然后给这个局域网(路由器)分配一个公网IP,这个局域网内的计算机和外网通信的时候就使用这个公网IP,而为了区分局域网内的各个计算机,路由器会请求STUN服务器给每个计算机(私网IP)分配一个编号(也可以叫端口号),使用公网IP和这个编号来和外网通信,发送数据包的时候,将IP和编号一起放在数据包的头部,这样路由器得到的应答数据包就可以根据端口号分发给私网内的计算机,这就是NAT网络地址转换。这样就可以在使用原来的IP协议的基础上,解决公网IP不足的问题。
STUN NAT会话穿越应用程序
NAT协议中,NAT端口的分配。以及[公网IP:NAT端口]到私网IP的映射,也是由STUN服务器完成,我们也可以
计算机发起请求前,先请求局域网内的STUN服务器,获取自己的公网IP和NAT端口。但是这还不够,还需要知道对方的公网IP和NAT端口才能进行通信,所以还需要一个公共发服务器保存各个计算机的公网IP和NAT端口,网络协商的时候不仅要从STUN服务器获取自己的公网IP和NAT端口,同时也要获得对方的公网IP和端口,这样才可以进行P2P通信。
[P2P学习(一)NAT的四种类型以及类型探测 - 山上有风景 - 博客园 (cnblogs.com)](https://www.cnblogs.com/ssyfj/p/14791064.html#:~:text=一般来讲, NAT可以分为四种类型,分别是%3A 1%2C 全锥型 (Full Cone) 2%2C 受限锥型,Cone)
四种NAT类型:
而如何获取内网的IP和端口,不属于STUN
和TURN
协议的范畴
TURN协议
TURN协议其实是STUN协议的扩展
P2P学习(三)网络传输基本知识—TURN协议 - 山上有风景 - 博客园 (cnblogs.com)
进行NAT转换后可能不能直接使用P2P协议(NAT
无法穿透)
此时可以使用一个中继服务器来进行转发,是对STUN协议的补充
使用TURN协议的客户端必须能够通过中继地址和对等端进行通讯,并且能够得知每个peer的的IP地址和端口(确切地说,应该是peer的服务器反射地址)。
而这些行为如何完成,是不在TURN协议范围之内的,比如可以使用一个信令服务器来传输这些信息
使用TURN
的优先级是最低的,因为它多了一段网络链路,时延增加,并且非常耗费网络带宽,是P2P的2倍,而服务器的带宽都是非常贵的。P2P需要的带宽是单向带宽的2倍,中继服务器则需要四倍(如下图所示,两个Peer都需要上传和下载)
在WebRTC中,描述网络信息的术语叫做candidate,前面描述媒体协商的媒体数据叫作SDP
ICE是集成STUN和TURN协议的框架
和信令服务器进行通信,可以使用各种不同的变成语言,并且可以自己设计指令,只要能完成交换彼此信息的功能即可
信令服务器需要搭建在通信的主机都能访问到的局域网里面(因为主机Peer需要通过信令服务器来交换SDP,candidate,所以必须保证主机能访问到),一般部署在公网里面,部署在公网里面,这样公网里面的主机都能访问到
信令服务器不仅要交换SDP,Candidate,还需要有房间机制(或者叫会话机制),一个房间里面的人可以相互通话,不同房间的人不能相互通话,所以我们需要知道本次会话包含哪些人(或者说,这个房间包含哪些人),所以就需要房间机制,需要对进出房间进行管理
STUN/TURN
服务器发送ICE请求,然后默认的回调函数OnICECandidate
会将Candidate信息保存对象内部,保存这一操作由对象自动完成。NAT
打洞是否能成功,如果能成功就直接使用P2P
进行通信,如果不行就使用中继服务器(STUN和TURN
)onAddStream
会接受以及发送对方的媒体流,这样就能完成双方的数据通信windows本地需要安装vscode,webstorm等js编译器,然后需要安装node和npm,下面介绍服务器安装流程
安装必要的依赖
ubuntu
apt-get install libssl-dev
apt-get install libevent-dev
centos
yum install libssl-dev
yum install libevent-dev
编译安装coturn
官方网站https://github.com/coturn/coturn
使用docker
Coturn/Docker/Coturn at Master ·转弯/转弯 ·GitHub
docker run -d --privileged=true --network=host -p 3478:3478 -p 3478:3478/udp -p 5349:5349 -p 5349:5349/udp -p 49152-65535:49152-65535/udp coturn/coturn
测试是否成功
lsof -i:3478
查看当前端口是否被监听
同时可以打开这个网站来测试STUN的功能 https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
测试STUN服务器的功能:
STUN不需要用户名密码,Priority显示Done
,表示打洞功能是正常的
测试TURN功能:
用户名密码默认都是服务器的用户和密码
显示done表示成功
播放视频的代码流程
onOpenCamera
是需要我们自己的编写的事件,在点击按钮后实现视频的播放
按钮自不必说使用标签,播放视频需要使用
标签,相关的属性如下
属性 | 值 | 描述 |
---|---|---|
autoplay | autoplay | 如果出现该属性,则视频在就绪后马上播放。 |
controls | controls | 如果出现该属性,则向用户显示控件,比如播放按钮。 |
height | pixels | 设置视频播放器的高度。 |
loop | loop | 如果出现该属性,则当媒介文件完成播放后再次开始播放。 |
muted | muted | 如果出现该属性,视频的音频输出为静音。 |
poster | URL | 规定视频正在下载时显示的图像,直到用户点击播放按钮。 |
preload | auto metadata none | 如果出现该属性,则视频在页面加载时进行加载,并预备播放。如果使用 “autoplay”,则忽略该属性。 |
src | URL | 要播放的视频的 URL。 |
width | pixels | 设置视频播放器的宽度。 |
playsinline | 无 | 设置后,用户无法拖动进度条 |
可以触发的事件:HTML 事件 | 菜鸟教程 (runoob.com)
初始的简易模板
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>VideoPlayertitle>
head>
<body>
<video id="local-video" autoplay playsinline>video>
<button id="showVideo">打开摄像头button>
<p>通过getUserMedia获取视频p>
body>
html>
getUserMedia
参考的API:https://developer.mozilla.org/zh-CN/docs/Web/API/MediaDevices/getUserMedia
可以从navigator.mediaDevices
里面获取浏览器可以使用的设备,而navigator.mediaDevices.getUserMedia
则可以用来获取视频,需要传入一个参数constraints
,是一个配置对象,里面是JSON格式的配置信息。获取到的这个流不一定是单个媒体的流,也可能是多个媒体混合在一起的流,在constraints
配置对象里面说明要采集的媒体,最后得到一个stream
对象,表示所有媒体的流
比如最简单的constraints
,audio
表示使用音频,video
表示使用视频
{ audio: true, video: true }
设置为true,表示使用这个媒体,并且所有的属性都取默认值。我们也可以进一步进行配置,比如设置宽高,也就是分辨率
{
audio: true,
video: { width: 1280, height: 720 }
}
也可以设置一个范围的分辨率,会尽可能使用接近ideal的值
{
audio: true,
video: {
width: { min: 1024, ideal: 1280, max: 1920 },
height: { min: 776, ideal: 720, max: 1080 }
}
}
还可以设置使用前置摄像头
{ audio: true, video: { facingMode: "user" } }
后置摄像头
{ audio: true, video: { facingMode: { exact: "environment" } } }
video
标签的媒体来源可以从src
属性指定的文件获取,也可以从srcObject
属性指定的流中获取,所以我们将从getUserMedia中获取的媒体流赋值给srcObject属性后,就可以在屏幕上显示视频
有了上面这些知识后就可以实现打开摄像头,获取媒体流的功能
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>VideoPlayertitle>
head>
<body>
<video id="local-video" autoplay playsinline>video>
<button id="showVideo">打开摄像头button>
<p>通过getUserMedia获取视频p>
body>
<script>
//摄像头的配置信息,指定需要采集的媒体有视频和音频
const constraints = {
audio: true,
video: true
};
//打开摄像头成功的回调函数,参数从摄像头设备里面会获取到的媒体流
function handleSuccess(stream) {
//媒体对象
const video = document.querySelector("#local-video");
video.srcObject = stream;
}
//打开摄像头按钮的回调函数
function onOpenCamera(e) {
// 获取媒体流,获取成功后触发回调函数handleSuccess,入参是获取到的媒体流
// 获取失败(比如用户不给权限或者电脑没有摄像头)则触发catch里面的失败函数,参数是错误信息
navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).catch(null);
}
//给id为showVideo添加点击事件
document.querySelector("#showVideo").addEventListener("click", onOpenCamera);
script>
html>
看到摄像头里的画面就表示摄像头正常,媒体流获取成功
上面这个例子技能播放视频也能播放音频,如果只想播放视频,将配置对象改成
const constraints = {
audio: false,
video: true
};
即可,也就是及那个audio改成false,表示不播放音频
想要达到不播放音频还可以这么做
<video id="local-video" autoplay playsinline muted>video>
加上muted
属性,这样虽然会接受音频流,但是不会播放声音
流程和打开麦克风差不多,只是处理媒体流的控件改成了audio
标签,video
标签既能播放视频也能播放音频,而audio
标签只能播放音频,不过音频和视频的传输速率不一样,我们可以分开处理(比如视频可能只能支持十几个人,音频却可以支持上百人,所以可以让小部分人视频,其他人只用音频,这样也能达到通话的效果)
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>audio playertitle>
head>
<body>
<audio id="localAudio" autoplay controls>播放麦克风的路由audio>
<button id="playAudio">打开麦克风button>
<p>通过getUserMedia()获取音频p>
body>
<script>
//媒体的配置信息,指定需要采集的媒体是音频,没有视频
const constraints = {
audio: true,
video: false
};
//打开麦克风成功的回调函数,参数从麦克风设备里面会获取到的媒体流
function handleSuccess(stream) {
//媒体对象
const audio = document.querySelector("#localAudio");
audio.srcObject = stream;
}
function handleError(err) {
console.log(err)
}
//打开麦克风按钮的回调函数
function onOpenMicrophone(e) {
// 获取媒体流,获取成功后触发回调函数handleSuccess,入参是获取到的媒体流
// 获取失败(比如用户不给权限或者电脑没有摄像头)则触发catch里面的失败函数,参数是错误信息
navigator.mediaDevices.getUserMedia(constraints).then(handleSuccess).catch(handleError);
}
//给id为playAudio添加点击事件
document.querySelector("#playAudio").addEventListener("click", onOpenMicrophone);
script>
html>
属性controls
可以为audio和video标签的控件加上一个额外的控件,来控制播放
nodejs实现信令服务器会比较方便,所以这里用nodejs实现信令服务器
websocket可以在进行一次握手后,建立持久的链接,后续双方就可以在这个连接上持续地传输数据。没有websocket之前,应用之间每次传输数据都需要发送http请求,实现服务器推送的功能也只能使用AJAX进行轮询,但是网络IO的轮询要频繁地进行握手和挥手,效率低,浪费流量。而使用websocket之后,进行一次握手后就可以进行持续地进行数据传输,在有信息到来后通知监听的一方执行回调函数,从而避免了轮询,后续的数据不需要再进行握手和挥手,大大提高了效率。
简单来说,Node.js就是运行在服务端的JavaScript
安装websocket(Linux或者windows)
cd 工程目录
npm init
npm init -y #表示所有选项都是用默认,所有yes或no都使用yes
npm install nodejs-websocket
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>聊天室客户端title>
head>
<body>
<h1>Websocket简易聊天h1>
<div id="app">
<label for="sendMsg">发送消息label><input id="sendMsg" type="text"/>
<button id="submitBtn">发送button>
div>
body>
<script>
//创建div元素,并放在body的后面
function showMessage(str, type) {
let div = document.createElement("div");
div.innerHTML = str;
if (type === "enter") div.style.color = "blue";
else if (type === "leave") div.style.color = "red";
document.body.appendChild(div);
}
//创建一个websocket
const websocket = new websocket("ws://114.116.28.229:8010")
//打开websocket链接
websocket.onopen = () => {
console.log("已经连接上服务器")
//为按钮绑定点击事件
document.getElementById("submitBtn").onclick = () => {
const txt = document.getElementById("sendMsg").value;
if (txt) {
//向服务器发送数据
websocket.send(txt)
}
}
//关闭链接的触发事件
websocket.onclose = () => {
console.log("websocket close")
}
//服务器发送过来的都是JSON包,包含type和data
websocket.onmessage = e => {
const mes = JSON.parse(e.data)
showMessage(mes.data,mes.type)
}
}
script>
html>
服务器端
服务器端的connection.sendText
被客户端的websocket.onmessage
监听
客户端的websocket.send()
函数,被服务器端的connection.on("text",()={})
监听
客户端关闭链接(关闭选项卡)会被客户端的
websocket.onclose
监听,和被服务器端的connection.on("close",()=>{})
监听
客户端建立socket链接new websocket
会被服务器端的ws.createServer
监听,回调函数就是得到的链接对象
信令服务器需要使用map来管理房间
js创建类可以通过方法的形式来创建,方法就体就是构造函数,通过this.xxx
来添加成员变量和成员方法
=>
代替function
关键字function(e){} 等价于 e=>{}
使用=>
更加简洁,只有一条语句大括号可以省略
let promise = Promise.resolve();
promise.then(() => {
console.log(1)
return 2;
}).then(num => {
console.log(num)
return 3;
}).then(num => {
console.log(num)
throw new Error()
}).catch(err => {
console.log(err)
})
如果没有出现异常就一直执行then,如果出现了异常,会先执行第一次遇到的catch,跳过这中间的then,然后执行后面的then
使用ajax时,会有成功和失败函数,我们可以使用promise对ajax的调用过程进行封装
使用格式:
let p=new Promise((resolve, reject) => {
resolve(data)
reject(err)
})
p.then((data)=>{
console.log(data);
}).catch(err=>console.log(err));
resolve的实现来自外面的then的参数
reject的实现来自于完美catch的实现
使用promise我们可以将ajax的成功和失败函数封装到resolve和reject中,并将函数的实现延迟到外面
我们可以使用promise将异步请求封装起来
使用ajax之前需要引入
function get(url, data,method) {
return new Promise((resolve, reject) => {
$.ajax({
url: url,
data: data,
method:method,
success(data) {
resolve(data)
},
error(err) {
reject(err)
}
})
})
}
然后我们就可以按照我们习惯的方式来发生异步请求:
get("./user.json","get").then((data) => {
console.log(data)
return get(`./user${data.userId}.json`,"get")
}).then((data) => {
console.log(data)
return get(`./course.json`,"get")
}).then((data) => {
console.log(data)
}).catch((err) => {
console.log(err)
})
每发送一个请求都return一个promise,这样调用结束后就可以继续.then进行下一步处理,javascript并不是一个严格的语言,函数参数不全并不会报错
在then里面传入请求成功后的方法,在catch里面传入请求失败的方法,参数都由get方法中ajax的回调的返回值来设置
不同Promise和主线程是异步执行的,而Promise内部是顺序执行的,只有前面的then执行完,才会执行后面的then
(876条消息) webrtc一对一通话_Lumos`的博客-CSDN博客
大致分为这几步:打开设备,媒体协商,网络协商,传输数据,关闭设备
我们首先要设计信令,所谓信令其实就是通知的类型,告诉服务器或者客户端发生了什么,然后服务端和客户端就可以监听这些信令进行对应的处理
join
,加入指定的房间,回复当前房间是否有人,以及有哪些人RTCPeerConnection
对象,也就是通信双方创建链接RTCPeerConnection
链接对象里面offer
发起媒体协商,并由信令服务器转发RTCPeerConnection
,将自己的媒体流设置进链接,保存对方的媒体信息,获取自己的媒体信息,作为答案返回给对方STUN/TURN
服务器,发送ICE请求,获取打洞地址,然后将这个信息candidate
发送给对方,收到candidate
信息后保存下来,完成网络协商leave
信令,关闭链接和音视频(不再显示音视频)。信令服务器收到leave信令后从房间里面删除PeerA
,并通知房间里面剩下的人PeerB
,PeerB
收到leave
信令后也关闭链接和媒体流这个简易的一对一通话系统需要如下信令,每个信令都传输JSON格式的数据,接收端解析JSON后就可以知道是什么信令以及有哪些数据
join
var jsonMsg={
'cmd':'join',
'roomId':roomId,
'uid':localUserId
}
resp-join
jsonMsg={
'cmd':'resp-join',
'remoteUid':remoteUid
}
leave
var jsonMsg={
'cmd':'leave',
'roomId':roomId,
'uid': localUserId
}
new-peer
var jsonMsh={
'cmd':'new-peer',
'remoteUid':uid
}
offer
var jsonMsg={
'cmd':'offer',
'roomId':roomId,
'uid':localUserId,
'remoteUid':remoteUserId,
'msg':JSON.stringify(sessionDescription)
}
answer
var jsonMsg={
'cmd':'answer',
'roomId':roomId,
'uid':localUserId,
'remoteUid':remoteUserId,
'msg':JSON.stringify(sessionDescription)
}
candidate
var jsonMsg={
'cmd':'candidate',
'roomId':roomId,
'uid':localUserId,
'remoteUid':remoteUserId,
'msg':JSON.stringify(candidate)
}
房间机制需要这样的一个Map
key是房间号,value是房间对象,房间对象也是一个Map,key是客户端uid,value是客户端对象,客户端对象里面保存有uid,roomId和链接对象conn
基本格式:
aPromise=myPeerConnection.createOffer([options]);
调试技巧:在你在修改的地方的前面打上断点,然后修改为自己想要的代码,然后保存,然后放行
前面两个参数很好理解,选择是否要接受视频和音频,第三个参数iceRestart
,如果设置为true,每次ICE请求都会重新获取自己的打洞信息,如果设置为false,只有刚刚启动(还不是活跃状态)的时候会获取打洞信息,启动之后(活跃状态)就不会再获取了,使用之前的打洞信息
基本格式
aPromise=RTCPeerConnection.createAnswer([options])
[options]的格式和上面一样,只是这个是应答,作用是一样的
获取并设置本地的媒体信息
aPromise = RTCPeerConnection.setLocalDescription(sessionDescription);
保存对方传来的媒体信息
aPromise = pc.setRemoteDescription(sessionDescription);
Android和Web端的Candidate信息可能不同(名称不同)
语法:
pc = new RTCPeerConnection([configuration])
[configuration]
也是一个JSON格式的配置对象,可以配置如下信息
测试的时候建议使用relay,因为如果使用all,即使打洞失败也能通,因为在局域网内不需要公网IP,而如果relay可以通,说明coturn服务器确实是可以使用的,那么部署到公网也肯定没问题
iceServer其实就是coturn服务器,url需要带上stun
或者turn
的前缀,stun不需要密码,而turn需要用户名和密码
实现的时候可以将信令使用常数保存起来,方便后面使用
//信令组
//客户端加入房间
const SIGNAL_TYPE_JOIN = "join";
//服务器对加入房间的回应
const SIGNAL_TYPE_RESP_JOIN = "resp-join";
//客户端离开房间
const SIGNAL_TYPE_LEAVE = "leave";
//服务器通知所有用户有新用户加入
const SIGNAL_TYPE_NEW_PEER = "new-peer";
//服务器通知所有用户有用户离开
const SIGNAL_TYPE_PEER_LEAVE = "peer-leave";
//发起媒体协商的数据
const SIGNAL_TYPE_OFFER = "offer";
//回应媒体协商的数据
const SIGNAL_TYPE_ANSWER = "answer";
//发送网络协商的数据
const SIGNAL_TYPE_CANDIDATE = "candidate";
//提示信息
const SIGNAL_TYPE_INFO = "info";
index.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket客户端title>
head>
<body>
<h1>WebRTC demoh1>
<div id="buttons">
<label for="zero-RoomId">加入房间ID label><input type="text" id="zero-RoomId" placeholder="请输入房间ID" maxlength="40"/>
<button id="joinBtn" type="button">加入button>
<button id="leaveBtn" type="button">离开button>
div>
<div id="videos">
<video id="localVideo" autoplay muted playsinline>本地窗口video>
<video id="remoteVideo" autoplay playsinline>对方窗口video>
div>
body>
html>
需要写一份js代码来监听各种事件
js/main.js
'use strict'
//显示本地视频
let localVideo = document.querySelector("#localVideo")
//显示远端的视频
let remoteVideo = document.querySelector("#remoteVideo")
//加入按钮
let joinBtn = document.getElementById("joinBtn");
//保存本地的媒体流
let localStream=null;
//getUserMedia的回调函数的参数里面有stream,表示媒体流
// 这个媒体流可以拷贝一份保存在本地,方便后面传输给对面
function openLocalStream(stream) {
console.log("open local stream")
localVideo.srcObject=stream
localStream=stream
}
//绑定加入按钮的点击事件,点击后这里会获取媒体流并交给处理函数
joinBtn.onclick = () => {
console.log("加入按钮被点击")
//初始化本地码流
navigator.mediaDevices.getUserMedia({
audio: true,
video: true
}).then(openLocalStream).catch(e => {
console.log("getUserMedia() error: " + e.name)
})
}
index.html
index.html要引入刚才编写的js文件
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebSocket客户端title>
head>
<body>
...
body>
<script src="js/main.js">script>
html>
onMessage的回调函数的参数是一个JSON对象,里面包含如下字段,其中data属性表示服务器传过来的数据,对应服务器的sendText
方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MqWIx4Kj-1669259633020)(D:\学习笔记\picture\image-20221121111456358.png)]
server.js
const ws = require("nodejs-websocket")
const PORT = 8099;
const server = ws.createServer(conn => {
console.log("创建了一个新的链接")
conn.sendText("收到你的链接了")
conn.on("text", str => {
console.log("服务器收到消息:", str)
})
conn.on("close", (code, reason) => {
console.log("链接关闭 code:", code, reason)
})
conn.on("error",err=>{
console.log("监听到错误:",err)
})
})
server.listen(PORT)
js/main.js
...
//创建一个方法其实也就是创建了一个类,类名和变量名可以重名
function ZeroRTCEngine(webSocketUrl) {
this.init(webSocketUrl)
return this
}
ZeroRTCEngine.prototype.init = (websocketUrl) => {
let zp = ZeroRTCEngine.prototype
zp.wsUrl = websocketUrl
zp.signaling = null
}
//websocket打开的时候
ZeroRTCEngine.prototype.onOpen = () => {
console.log("websocket open")
}
//websocket接收到数据的时候
ZeroRTCEngine.prototype.onMessage = event => {
console.log("onMessage: ", event)
}
//websocket出现错误的时候
ZeroRTCEngine.prototype.onError = event => {
console.log("onError: ", event)
}
//websocket关闭的时候
ZeroRTCEngine.prototype.onClose = event => {
console.log("onClose -> code : ", event.code, ", reason:", EventTarget.reason)
}
ZeroRTCEngine.prototype.createWebsocket = () => {
let zp = ZeroRTCEngine.prototype
zp.signaling = new WebSocket(zp.wsUrl)
zp.signaling.onopen = zp.onOpen
zp.signaling.onmessage = zp.onMessage
zp.signaling.onerror = zp.onError
zp.signaling.onclose = zp.onClose
console.log(zp)
}
const IP = "127.0.0.1"
const PORT = 8099;
let zeroRTCEngine = new ZeroRTCEngine(`ws://${IP}:${PORT}`);
zeroRTCEngine.createWebsocket()
...
JOIN信令是客户端发送给服务器的信令,表示加入房间
join信令格式
var jsonMsg={
'cmd':'join',
'roomId':roomId,
'uid':localUserId
}
获取用户自己的ID
let userName = prompt("输入您的昵称");
//为每个用户生成一个自己的ID
let localUserId = userName + "_" + crypto.randomUUID().replaceAll("-", "");
发送JOIN信令
//发送JOIN信令
function doJoin(roomId) {
const jsonMsg = {
'cmd': 'join',
'roomId': roomId,
'uid': localUserId
};
//socket的send方法只能发送字符串,所以发送前要将对象序列化成JSON字符串
let message = JSON.stringify(jsonMsg);
zeroRTCEngine.sendMessage(message);
console.log("doJoin message", message);
}
发送JOIN信令其实就是使用websocket长连接来发送
zp.sendMessage = message => {
zp.signaling.send(message);
};
发送时机是点击发送按钮后发送
joinBtn.onclick = () => {
console.log("加入按钮被点击");
roomId = document.getElementById("zero-RoomId").value;
//向信令服务器发送加入信令
doJoin(roomId);
//初始化本地码流
initLocalStream();
};
浏览器启动的时候,会和服务器端创建WebSocket链接,创建成功后,服务器会向客户端发送一条消息,表示链接建立成功
客户端的onmessage方法会监听接收消息的事件(onmessage方法最终的实现就是这个onMessage方法),收到服务器的消息后,这里会将其打印出来
onmessage的回调函数是一个JSON对象,内容如下,里面的data属性是服务器传过来的数据
输入房间号,点击加入按钮后,就会发生:
JOIN
信令function doJoin(roomId) {
const jsonMsg = {
'cmd': 'join',
'roomId': roomId,
'uid': localUserId
};
//socket的send方法只能发送字符串,所以发送前要将对象序列化成JSON字符串
let message = JSON.stringify(jsonMsg);
zeroRTCEngine.sendMessage(message);
console.log("doJoin message", message);
}
服务器端使用websocket.on(“text”)方法来监听各个信令,链接持续过程中的所有数据都由这个方法来处理
conn.on("text", str => {
console.log("服务器收到消息:", str)
})
客户端传来的是JSON字符串,服务器收到后进行反序列化就能使用,可以通过type
属性区分不同的类型,来进行分门别类的处理。
conn.on("text", str => {
console.log("服务器收到消息:", str);
let jsonMsg = JSON.parse(str);
switch (jsonMsg.cmd) {
case SIGNAL_TYPE_JOIN:
handleJoin(jsonMsg, conn);
break;
}
});
处理JSON信令的服务端方法:
let roomTableMap = new Map();
/**
* 处理信令的函数
* @param jsonMsg 传输的数据
* @param conn socket链接
*
* const jsonMsg = {
* 'cmd': 'join',
* 'roomId': roomId,
* 'uid': localUserId
* };
*
*/
function handleJoin(jsonMsg, conn) {
const roomId = jsonMsg.roomId;
const uid = jsonMsg.uid;
console.log("user-", uid, "try to join room ", roomId);
let roomMap = roomTableMap.get(roomId);
//房间不存在就创建
if (roomMap == null) {
roomMap = new Map();
roomTableMap.set(roomId, roomMap);
}
// 因为是一对一通话,所以要判断房间人数不能大于2
if (roomMap.size >= MAX_COUNT) {
console.error("房间-", roomId, " 已经有两人存在");
conn.sendText(`房间-${roomId} 已经满了`);
return;
}
let client = new Client(uid, conn, roomId);
roomMap.set(uid, client);
//如果原来房间里面有人
if (roomMap.size > 1) {
for (const [iterUid, iterClient] of roomMap) {
if (iterUid !== uid) {
//告诉另一个人有新用户来了,发送new-peer信令
let newPeerJsonMsg = {
'cmd': SIGNAL_TYPE_NEW_PEER,
'remoteUid': uid
};
let newPeerMsg = JSON.stringify(newPeerJsonMsg);
iterClient.conn.sendText(newPeerMsg);
//回复新用户另一个人是谁
let joinRespMsg = {
"cmd": SIGNAL_TYPE_RESP_JOIN,
"remoteUid": iterUid
};
let respJoinMsg = JSON.stringify(joinRespMsg);
console.log("resp-join: ", respJoinMsg);
conn.sendText(respJoinMsg)
}
}
}
}
服务器收到JOIN
信令后,要判断这个房间是否有用户,如果有用户,则需要使用resp-join
信令通知新用户房间里有其他用户。同时需要使用new-peer
信令通知房间原来的用户有新用户加入
房间机制其实是一个Map
这样的一个map对象,保存有房间信息和用户信息
同样的,客户端收到的来自服务器的信令都会交给onMessage这个回调函数来处理,同样是解析为JSON对象后,通过cmd
属性来判断信令的类型,从而进行分门别类的处理
//websocket接收到数据的回调函数
zp.onMessage = event => {
console.log("onMessage: ", event.data);
let jsonMsg = JSON.parse(event.data);
switch (jsonMsg.cmd) {
case SIGNAL_TYPE_NEW_PEER:
handleNewPeer(jsonMsg);
break;
case SIGNAL_TYPE_RESP_JOIN:
handleResponseJoin(jsonMsg);
break;
}
};
resp-join
信令格式:
jsonMsg={
'cmd':'resp-join',
'remoteUid':remoteUid
}
当前客户端加入房间后,如果有其他Peer在房间里面,就会收到服务器发来的resp-join
信令,以此得知对方的id
/**
* 处理respJoin信令的处理函数
* @param respMsg json对象
* {
* 'cmd':'resp-join',
* 'remoteUid':remoteUid
* }
*/
function handleResponseJoin(respMsg) {
console.log(`handleResponseJoin, the old one is user-${respMsg.remoteUid}`);
remoteUserId = respMsg.remoteUid;
doOffer();
}
得到对方的userId后,信令服务器就可以来转发给另一个Peer的信息,所以下面需要doOffer
发起媒体协商
new-peer
信令格式:
var jsonMsh={
'cmd':'new-peer',
'remoteUid':uid
}
客户端收到服务器发来的new-peer
信令后用于处理的回调函数,作用和resp-join
信令一致,用于得知对方的id
/**
* 处理new-peer信令的信息
* @param newPeerMsg json对象
* {
* 'cmd':'new-peer',
* 'remoteUid':uid
* }
*/
function handleNewPeer(newPeerMsg) {
console.log(`handleNewPeer, the new one is user-${newPeerMsg.remoteUid}`);
remoteUserId = newPeerMsg.remoteUid;
}
得到对方的userId后,信令服务器就可以来转发给另一个Peer的信息,然后等待加进来的那一方发起媒体协商的offer
即可
实现思路:
peer-leave
事件peer-leave
事件处理离开信令
/**
* 处理离开信令
* @param jsonMsg 信令信息
* @param conn 客户端链接
*/
function handleLeave(jsonMsg, conn) {
const roomId = jsonMsg.roomId;
const uid = jsonMsg.uid;
console.log("用户", uid, "尝试离开房间", roomId);
let roomMap = roomTableMap.get(roomId);
if (roomMap == null) {
let errMsg = "找不到房间" + roomId;
console.error(errMsg);
conn.sendText(JSON.stringify({'cmd': 'info', 'data': errMsg}));
return;
}
if (roomMap.get(uid)) {
roomMap.delete(uid);
for (let [iterUid, iterClient] of roomMap) {
let peerLeaveMsg = {
'cmd': 'peer-leave',
'remoteUid': uid
};
console.log('发送peer-leave信令,', peerLeaveMsg);
iterClient.conn.sendText(JSON.stringify(peerLeaveMsg));
}
} else {
const errMsg = `用户${uid}并不在房间${roomId}`;
conn.sendText(JSON.stringify({'cmd': 'info', 'data': errMsg}));
console.error(errMsg);
}
}
发送leave信令
/**
* 处理离开事件
* @param roomId 房间号
*/
function doLeave(roomId) {
const jsonMsg = {
'cmd': 'leave',
'roomId': roomId,
'uid': localUserId
};
//socket的send方法只能发送字符串,所以发送前要将对象序列化成JSON字符串
let message = JSON.stringify(jsonMsg);
zeroRTCEngine.sendMessage(message);
console.log("doLeave message", message);
}
处理leave-peer信令
/**
* 处理peer-leave信令
* @param jsonMsg 相关数据
*/
function handlePeerLeave(jsonMsg) {
console.log("handlePeerLeave 收到消息", jsonMsg);
}
收到的信令都是一个json字符串,解析成对象后,根据cmd属性调用方法即可
这些信令用于媒体协商和网络协商,由RTCPeerConnection
内部自己完成,而我们只需要处理它的回调。offer创建者是在创建offer的时候创建RTCPeerConnection
对象,而接受端是收到answer
信令后创建RTCPeerConnection
对象。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t5p3sJ5w-1669259633022)(D:\学习笔记\picture\image-20221122152525767.png)]
offer是创建RTCPeerConnection将自己的媒体信息发送给对方,在收到new-peer
信令的时候发送offer
信令(执行doOffer
方方法)
function handleNewPeer(newPeerMsg) {
console.log(`handleNewPeer, the new one is user:${newPeerMsg.remoteUid}`);
remoteUserId = newPeerMsg.remoteUid;
doOffer();
}
接下来我们将接下来将后面的逻辑放在doOffer方法里面
/**
* 媒体协商所需要的函数
*/
function doOffer() {
//创建RTCPeerConnection
if(rtcPeerConnection==null){
rtcPeerConnection=createPeerConnection();
}
...
}
doOffer首先要创建RTCPeerConnection对象,这个对象需要绑定收到candidate和track信息的回调函数,同时需要绑定本地媒体流的track信息
(stream其实是媒体流对象,一个可以获取字符流的接口,本地媒体流从设备中来,对方的媒体流从网络中来,后面需要继续完善所需的信息,才能从对方那里获取字符流)
使用addTrack方法需要我们引入adapter-latest.js
文件
(873条消息) 关于adapter.js_sxc1989的博客-CSDN博客_adapter-latest.js
如果是在html的js使用,可以直接在代码前面引入
https://webrtc.github.io/adapter/adapter-latest.js
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FOhJWkAx-1669259633023)(D:\学习笔记\picture\image-20221123003534913-1669134937109-1.png)]
WebStorm可以帮我们从这些链接下载文件,避免每次运行都需要下载
如果是node.js,可以使用
npm install webrtc-adapter
下载依赖,然后就可以使用require
引入
创建RTCPeerConnection,在这里绑定本地流
//创建RTCPeerConnection对象
function createPeerConnection() {
const defaultConfiguration = {
bundlePolicy: "max-bundle",
rtcpMuxPolicy: "require",
iceTransportPolicy: "all",//relay 或者 all
// 修改ice数组测试效果,需要进行封装
iceServers: [
{
"urls": [
`turn:${COTURN_IP}:${COTURN_PORT}?transport=udp`,
`turn:${COTURN_IP}:${COTURN_PORT}?transport=tcp` // 可以插入多个进行备选
],
"username": "root",
"credential": "lth123456@@"
},
{
"urls": [
`stun:${COTURN_IP}:${COTURN_PORT}`
]
}
]
};
let rtcPeerConnection = new RTCPeerConnection(defaultConfiguration);
//设置网络协商的回调函数
rtcPeerConnection.onicecandidate = handleRemoteIceCandidate;
//收到对方码流的回调函数,设置需要处理哪些流
rtcPeerConnection.ontrack = handleRemoteStreamAdd;
//保存本地码流,设置有哪些流需要传输
localStream.getTracks().forEach(track => rtcPeerConnection.addTrack(track, localStream));
return rtcPeerConnection;
}
使用RTCPeerConnection的createOffer方法获取自己的SDP信息,并在回到函数中发送给对方,这个逻辑封装在doOffer里面
/**
* 媒体协商所需要的函数
*/
function doOffer() {
console.log("doOffer");
//创建RTCPeerConnection
if (rtcPeerConnection == null) {
rtcPeerConnection = createPeerConnection();
}
//查询自己的sdp
rtcPeerConnection.createOffer().then(createOfferAndSendMessage).catch(handleCreateOfferError);
}
/**
* offer创建完成,拿到自己的的SDP后的回调函数,此时需要保存自己的SDP并发送给对方
* @param sessionDescription 自己的SDP信息
*/
function createOfferAndSendMessage(sessionDescription) {
//保存自己的SDP
rtcPeerConnection.setLocalDescription(sessionDescription)
.then(() => {
//然后将自己的SDP发送给对方
const sdpJsonMsg = {
'cmd': SIGNAL_TYPE_OFFER,
'roomId': roomId,
'uid': localUserId,
'remoteUid': remoteUserId,
'msg': JSON.stringify(sessionDescription)
};
const sdpJsonStringMsg = JSON.stringify(sdpJsonMsg);
zeroRTCEngine.sendMessage(sdpJsonStringMsg);
console.log("handleRemoteIceCandidate message :", sdpJsonMsg);
})
.catch(err => console.error("handleRemoteIceCandidate 出现错误:", err));
}
其实对于Offer,Answer,Candidate
信令,服务器都只是起到一个转发的作用,将转发给房间里的另一个人
首先先添加这三个信令的处理函数
conn.on("text", str => {
let jsonMsg;
try {
jsonMsg = JSON.parse(str);
} catch (e) {
console.warn("onMessage parse Json failed:", e);
return;
}
switch (jsonMsg.cmd) {
case SIGNAL_TYPE_JOIN:
uid = jsonMsg.uid;
handleJoin(jsonMsg, conn);
break;
case SIGNAL_TYPE_LEAVE:
handleLeave(jsonMsg, conn);
break;
case SIGNAL_TYPE_OFFER:
handleOffer(jsonMsg, conn);
break;
case SIGNAL_TYPE_ANSWER:
handleAnswer(jsonMsg, conn);
break;
case SIGNAL_TYPE_CANDIDATE:
handleCandidate(jsonMsg, conn);
break;
default:
console.log("服务器收到消息:", jsonMsg);
break;
}
});
编写转发函数,负责数据校验和内容转发
/**
* 将消息原封不动地转发给另一个用户
* @param jsonMsg 要转发的JSON对象
* @param conn 当前链接
*/
function forwardMessage(jsonMsg, conn) {
const roomId = jsonMsg.roomId;
const uid = jsonMsg.uid;
const remoteUid = jsonMsg.remoteUid;
console.log('转发消息', jsonMsg);
const userMap = roomTableMap.get(roomId);
if (userMap === null) {
let errMsg = "找不到房间" + roomId;
console.error(errMsg);
conn.sendText(JSON.stringify({'cmd': SIGNAL_TYPE_INFO, 'data': errMsg}));
return;
}
const localClient = userMap.get(uid);
if (localClient === null) {
const errMsg = `找不到用户${uid}`;
console.error(errMsg);
conn.sendText(JSON.stringify({'cmd': SIGNAL_TYPE_INFO, 'data': errMsg}));
return;
}
const remoteClient = userMap.get(remoteUid);
if (remoteClient === null) {
const errMsg = `找不到用户${uid}`;
console.error(errMsg);
conn.sendText(JSON.stringify({'cmd': SIGNAL_TYPE_INFO, 'data': errMsg}));
return;
}
//其实核心就这一行:转发offer信令给远程客户端
remoteClient.conn.sendText(JSON.stringify(jsonMsg));
}
这三个函数就调用forwardMessage
转发消息,并打印日志
/**
* 转发客户端发来的offer
* @param offerJsonMsg offer信令信息
* @param conn 客户端链接
*
* var jsonMsg={
* 'cmd':'offer',
* 'roomId':roomId,
* 'uid':localUserId,
* 'remoteUid':remoteUserId,
* 'msg':JSON.stringify(sessionDescription)
* }
*
*/
function handleOffer(offerJsonMsg, conn) {
console.log("handleOffer");
forwardMessage(offerJsonMsg, conn);
}
/**
* 转发客户端发来的Answer信令
* @param answerJsonMsg Answer信令信息
* @param conn 客户端链接
*
* var jsonMsg={
* 'cmd':'answer',
* 'roomId':roomId,
* 'uid':localUserId,
* 'remoteUid':remoteUserId,
* 'msg':JSON.stringify(sessionDescription)
* }
*
*
*/
function handleAnswer(answerJsonMsg, conn) {
console.log("handleAnswer");
forwardMessage(answerJsonMsg, conn);
}
/**
* 转发客户端发来的Candidate信令
* @param candidateJsonMsg Candidate信令信息
* @param conn 客户端链接
*
* var jsonMsg={
* 'cmd':'candidate',
* 'roomId':roomId,
* 'uid':localUserId,
* 'remoteUid':remoteUserId,
* 'msg':JSON.stringify(candidate)
* }
*
*/
function handleCandidate(candidateJsonMsg, conn) {
console.log("handleCandidate");
forwardMessage(candidateJsonMsg, conn);
}
接收端接受到Offer,也创建RTCPeerConnection,然后调用doAnswer函数创建SDP,保存SDP,发送SDP
/**
* 处理offer信令,包含对方的sdp信息
* 收到offer信令后要将sdp保存下来,并返回answer信令作为应答
* @param offerJsonMsg JSON对象,包含以下属性(服务器端按照这种格式发送)
* var offerJsonMsg={
* 'cmd':'offer',
* 'roomId':roomId,
* 'uid':localUserId,
* 'remoteUid':remoteUserId,
* 'msg':JSON.stringify(sessionDescription)
* }
*
*/
function handleRemoteOffer(offerJsonMsg) {
console.log("handleRemoteOffer ");
if (rtcPeerConnection == null) {
rtcPeerConnection = createPeerConnection();
}
doAnswer(offerJsonMsg);
}
具体的逻辑封装在doAnswer
函数里面,注意这里调用的是createAnswer
而不是createOffer
/**
* 收到offer后的应答,也需要创建offer后发给对方
*/
function doAnswer(offerJsonMsg) {
//查询自己的sdp,then回调函数的参数就是查询到的sdp信息
console.log("doAnswer");
//保存对方的SDP
rtcPeerConnection.setRemoteDescription(JSON.parse(offerJsonMsg.msg));
//查询自己的sdp
rtcPeerConnection.createAnswer()
.then(createAnswerAndSendMessage)
.catch(handleCreateAnswerError)
.catch(err => console.error("doAnswer setRemoteDescription", err));
}
查询到自己的SDP后,保存并发送
function createAnswerAndSendMessage(sessionDescription) {
//保存自己的SDP
rtcPeerConnection.setLocalDescription(sessionDescription)
.then(() => {
//然后将自己的SDP发送给对方
const sdpJsonMsg = {
'cmd': SIGNAL_TYPE_ANSWER,
'roomId': roomId,
'uid': localUserId,
'remoteUid': remoteUserId,
'msg': JSON.stringify(sessionDescription)
};
const sdpJsonStringMsg = JSON.stringify(sdpJsonMsg);
zeroRTCEngine.sendMessage(sdpJsonStringMsg);
console.log("send answer message :", sdpJsonMsg);
})
.catch(err => console.error("answer setLocalDescription 出现错误:", err));
}
/**
* 转发客户端发来的Answer信令
* @param answerJsonMsg Answer信令信息
* @param conn 客户端链接
*
* var jsonMsg={
* 'cmd':'answer',
* 'roomId':roomId,
* 'uid':localUserId,
* 'remoteUid':remoteUserId,
* 'msg':JSON.stringify(sessionDescription)
* }
*
*
*/
function handleAnswer(answerJsonMsg, conn) {
console.log("handleAnswer");
forwardMessage(answerJsonMsg, conn);
}
收到answer信令后,保存远端SDP(保存自己的SDPsetLocalDescription
,保存对方的SDPsetRemoteDescription
)
function handleRemoteAnswer(answerJsonMsg) {
console.log("收到应答", answerJsonMsg);
let desc = JSON.parse(answerJsonMsg.msg);
rtcPeerConnection.setRemoteDescription(desc)
.catch(err => console.error("handleRemoteAnswer 出现错误,", err));
}
在前面创建RTCPeerConnection的时候需要设置这些回调事件,这里会触发ontrack事件,获取到对方的码流,这样赋值给remoteVideo.srcObject
就能实现视频的传输
/**
* 收到对方媒体流的回调函数
* @param event 传递过来的json对象,媒体流就放在event.stream里面,这是一个数组
*/
function handleRemoteStreamAdd(event) {
console.log(`handleRemoteStreamAdd `, event);
remoteStream = event.streams[0];
remoteVideo.srcObject = remoteStream;
}
这个阶段会发送和获取多个Candidate信息,找到能打洞的Candidate信息
/**
* 收到网络协商信息的回调函数
* @param event 网络协商的信息
*/
function handleRemoteIceCandidate(event) {
if (event.candidate) {
const candidateJsonMsg = {
'cmd': SIGNAL_TYPE_CANDIDATE,
'roomId': roomId,
'uid': localUserId,
'remoteUid': remoteUserId,
'msg': JSON.stringify(event.candidate)
};
const candidateMsg = JSON.stringify(candidateJsonMsg);
zeroRTCEngine.sendMessage(candidateMsg);
console.log(`handleRemoteIceCandidate message`);
} else {
console.warn("End of candidates");
}
}
至此,一对一音视频通话的基本功能就完成了
可以添加一个UserMap
,保存用户所在的房间,服务器在处理Join
信令后,保存Uid,这样在链接端口或者出现错误的时候,就可以将用户从房间里面删除
const server = ws.createServer(conn => {
console.log("创建了一个新的链接");
//当前链接的用户ID
let uid;
conn.on("text", str => {
let jsonMsg;
try {
jsonMsg = JSON.parse(str);
} catch (e) {
console.warn("onMessage parse Json failed:", e);
return;
}
switch (jsonMsg.cmd) {
case SIGNAL_TYPE_JOIN:
//用户加入后,保存用户ID
uid = jsonMsg.uid;
handleJoin(jsonMsg, conn);
break;
case SIGNAL_TYPE_LEAVE:
handleLeave(jsonMsg, conn);
break;
case SIGNAL_TYPE_OFFER:
handleOffer(jsonMsg, conn);
break;
case SIGNAL_TYPE_ANSWER:
handleAnswer(jsonMsg, conn);
break;
case SIGNAL_TYPE_CANDIDATE:
handleCandidate(jsonMsg, conn);
break;
default:
console.log("服务器收到消息:", jsonMsg);
break;
}
});
conn.on("close", (code, reason) => {
userExit(uid);
});
conn.on("error", err => {
console.log("监听到错误:", err);
userExit(uid);
});
});
将用户从房间里删除
function userExit(uid) {
const roomId = userRoomMap.get(uid);
if (roomId != null) {
userRoomMap.delete(uid);
let userMap = roomTableMap.get(roomId);
userMap.delete(uid);
console.log(`用户${uid}退出房间${roomId}`);
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A2VDZxF4-1669259633023)(D:\学习笔记\picture\image-20221122233826766.png)]
offer信令里面包含本地的uid和p2p对方的uid,这个实际上可以当成一个链接,如果在多人聊天的时候,可以通过两两之间建立p2p链接来传输音视频
关闭选项卡的时候会出现服务器会监听到错误而不是监听到关闭链接:
Error: read ECONNRESET
at TCP.onStreamRead (node:internal/stream_base_commons:217:20) {
errno: -4077,
code: 'ECONNRESET',
syscall: 'read'
}
所以需要在关闭链接以及出现错误的时候,让当前用户退出所在房间
点击加入按钮的时候我们进行如下操作:
//绑定加入按钮的点击事件,点击后这里会获取媒体流并交给处理函数
joinBtn.onclick = () => {
console.log("加入按钮被点击");
if (!setRoomId()) return;
initLocalStream()
//向信令服务器发送加入信令
doJoin(roomId);
};
因为doJoin
方法要用到localStream
,这个变量是在initLocalStream
方法中getUserMedia
执行完后进行赋值,但是这个返回的是一个Promise,会和主线程异步执行,所以initLocalStream
和doJoin
谁先执行完是未知的
//初始化本地码流
function initLocalStream() {
return new Promise((resolve, reject) => {
navigator.mediaDevices.getUserMedia({audio: true, video: true})
.then(openLocalStream)
.then(resolve)
.catch(e => {
console.log("getUserMedia() error: " + e.name);
reject();
});
});
}
解决方法是再使用一个Promise,在里面.then(openLocalStream)
的后面加上.then(resolve)
,同一个Promise的then方法是按序执行的,而再使用一个Promise可以将内部的实现抛出到外面,所以外部这么写即可确保执行的顺序
//绑定加入按钮的点击事件,点击后这里会获取媒体流并交给处理函数
joinBtn.onclick = () => {
console.log("加入按钮被点击");
if (!setRoomId()) return;
/*
初始化本地码流,必须先于JOIN,因为后面要将码流添加进RTCPeerConnection,所以在添加之前就要获取到localStream
又因为getUserMedia是一个Promise,获取流的操作会异步执行,所以如果直接在下面写doJoin,可能initLocalStream还没有执行完就开始执行
doJoin,导致后面要使用localStream的时候还是null,解决办法是在then(openLocalStream)的后面在加上then,因为同一个Promise的then
一定是顺序执行的,再创建一个新的Promise,把下一步的实现抛出来,就可以解决这个问题
*/
initLocalStream().then(() => {
//向信令服务器发送加入信令
doJoin(roomId);
});
};
doOffer是发起offer,双方只能有一方调用这个函数,否则会造成混乱。而根据时序图,先到房间的先发起Offer,所以在处理完new-peer
信令后发起offer