音视频WebRTC实时通讯

223 阅读13分钟

本次主要分享基于WebRTC协议的音视频通话技术,本文讲解了公网中是如何进行视频通话的,实际实践中由于没有Linux系统,所以暂且使用的是内网实现的视频通话。另外,本次实现了 web-web 端和 web-app 端的视频通话。

WebRTC

WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输

P2P通信原理

P2P(Peer-to-Peer) 通信即点对点通信

P2P.png

问题

要想实现WebRTC需要完成以下几点:

1、如何找到对方

2、不同的音视频编解码能力如何沟通

3、如何联系上对方

解决

这里需要明白一个概念,两者进行连接当然是需要通过 IP + 端口进行连接,毕竟只有通过 IP 才能找到对应的设备

1、在 P2P 通信的过程中,双方需要交换一些元数据比如媒体信息、网络数据等等信息,我们通常称这一过程叫做“信令(signaling)”,对应的服务器即“信令服务器 (signaling server)”,我们通常使用信令服务器并通过 websocket让双方加入房间,然后向房间中发送事件,从而达到找到对方的目的。这里的“交换一些媒体信息”和“网络数据”实际上就是进行 媒体协商网络协商

概念须知

  • 信令服务器:传递双方信息的服务器就是信令服务器,此服务其实就是 web 服务,其职责也不止传输媒体格式以及网络信息,还可传输业务信息。其传输信息的协议可是 HTTP 或 Socket 等(我们这里使用的是 websocket)。

  • 媒体协商(SDP) :两个用户在连接之前相互确定并交换双方支持的音视频格式的过程就是媒体协商。SDP 是描述信息的一种格式,其格式组成可自行查找了解;

  • 网络协商(candidate) :两个用户在 NAT 后交换各自的网络信息的过程就是网络协商。candidate 也是一种描述信息的一种格式,其格式组成可自行查找了解。

2、不同浏览器对于音视频的编解码能力是不同的。比如: 以日常生活中的例子来讲,小李会讲汉语和英语,而小王会讲汉语和法语。为了保证双方都可以正确的理解对方的意思,最简单的办法即取他们都会的语言,也就是汉语来沟通。在 WebRTC 中:有一个专门的协议,称为 Session Description Protocol(SDP),可以用于描述上述这类信息。

因此:参与音视频通讯的双方想要了解对方支持的媒体格式,必须要交换 SDP 信息。而交换 SDP 的过程,通常称之为媒体协商。

3、第三个问题可以通过内网映射(NAT)的方式解决,也就是搭建 STUN/TURN 服务器,从而得到双方公共的网络地址,有了公共的网络地址不就可以使用 P2P 联系到对方了,这个技术我们可以称之为ICE。ICE 框架包含了以下几个步骤:

(1). 收集网络接口信息,包括本地 IP 地址、端口等;

(2). 通过 STUN 服务器获取公网 IP 地址和端口号;

(3). 通过 NAT 透明性检测来确定 NAT 类型和行为;

(4). 尝试直接连接对等端点;

(5). 如果直接连接失败,则使用 TURN 服务器作为中继节点进行连接。 也就是,ICE 更好的进行 NAT 穿越效果,从而提高实时通信的质量和效率。

概念须知

  • STUN:STUN 是一种网络协议,其目的是进行 NAT 穿越。 内网进行 NAT 后进行 P2P 连接会有两个问题:

    1. 由于 NAT 的安全机制,NAT 会过滤掉一些外网主动发送到内网的报文,而 P2P 恰恰就需要主动发起访问;
    2. NAT 后,会得到一个 IP + 端口的地址,而在进行 P2P 连接时并不知道这个地址,难道要用户手动填写吗。

    所以 STUN 的作用就是能够检测网络中是否存在 NAT 设备,有就可以获取到 NAT 分配的 IP + 端口地址,然后建立一条可穿越 NAT 的 P2P 连接(这一过程就是打洞)。

  • TURN:TURN 是 STUN 协议的扩展协议,其目的是如果 STUN 在无法打通的情况下,能够正常进行连接,其原理是通过一个中继服务器进行数据转发,此服务器需要拥有独立的公网 IP。

    TURN 很明显的一个问题就是其转发数据所产生的带宽费用需要由自己承担!

STUN TURN.png

流程

通讯.png

首先先使用 websocket 连接到信令服务器,使 Caller、Called 进入到同一个房间。

// ---------------前端代码---------------
const roomId = "001";

sock.on("connectionSuccess", () => {
    console.log("连接成功...");
    // 向服务器发送一个加入
    sock.emit("joinRoom", roomId);
});

// ---------------服务端代码---------------
// 加入房间
sock.on('joinRoom', roomId => {
    console.log('joinRoom')
    sock.join(roomId)
})

用户A发起视频请求并通知用户B

// ---------------前端代码---------------
// 发起方发起视频请求
const callRemote = async () => {
    // 用户A向用户B发起视频请求
    caller.value = true; // 表示当前用户是发起方
    calling.value = true; // 表示是否呼叫中

    localStream.value = await getLocalStream();

    // 通过信令服务器向用户B发起视频请求
    socket.value?.emit("callRemote", roomId);
};

// 接收方同意视频请求
const acceptCall = () => {
    console.log("同意视频邀请");

    // 通过信令服务器通知用户A
    socket.value?.emit("acceptCall", roomId);
};

// ---------------服务端代码---------------
// 发起方发起视频请求
sock.on('callRemote', roomId => {
    console.log('callRemote')
    io.to(roomId).emit('callRemote')
})

// 发起方收到接收方同意视频的请求
sock.on('acceptCall', roomId => {
    console.log('acceptCall')
    io.to(roomId).emit('acceptCall')
})

开始交换 SDP 信息和 candidate 信息

1、用户A创建创建RTCPeerConnection,添加本地音视频流,生成offer,并且通过信令服务器将offer发送给用户B

// ---------------前端代码---------------
peer.value = new RTCPeerConnection();
// 添加本地音视频流
peer.value.addStream(localStream.value);
// 生成offer
const offer = await peer.value.createOffer({
    offerToReceiveAudio: 1,
    offerToReceiveVideo: 1,
});

// 在本地设置offer信息
await peer.value.setLocalDescription(offer);

// 发送offer
socket.value?.emit("sendOffer", { offer, roomId });

// ---------------服务端代码---------------
// 接收offer
sock.on('sendOffer', ({ offer, roomId }) => {
    console.log('sendOffer');
    io.to(roomId).emit('sendOffer', offer)
})

2、用户B收到用户A的offer,并创建自己的RTCPeerConnection,添加本地音视频流,设置远端描述信息,生成answer,并且通过信令服务器发送给用户A。

// ---------------前端代码---------------
// 创建自己的RTCPeerConnection
peer.value = new RTCPeerConnection();
// 添加本地音视频流
const stream = await getLocalStream();
peer.value.addStream(stream);

// 设置远端描述信息
await peer.value.setRemoteDescription(offer);
// 生成answer
const answer = await peer.value.createAnswer();
// 在本地设置answer信息
await peer.value.setLocalDescription(answer);
// 发送answer
socket.value?.emit("sendAnswer", { answer, roomId });

// ---------------服务端代码---------------
// 收到answer
sock.on('sendAnswer', ({ answer, roomId }) => {
    io.to(roomId).emit('sendAnswer', answer)
})

3、用户A收到用户B的answer,并获取candidate信息并且通过信令服务器发送candidate给用户B

// ---------------前端代码---------------
// 通过监听onicecandidate事件获取candidate信息
peer.value.onicecandidate = (event) => {
    if (event.candidate) {
        socket.value?.emit("sendCandidate", {
            roomId,
            candidate: event.candidate,
        });
    }
};

// ---------------服务端代码---------------
// 收到candidate
sock.on('sendCandidate', ({ roomId, candidate }) => {
    io.to(roomId).emit('sendCandidate', candidate)
})

4、用户B添加用户A的candidate信息

// ---------------前端代码---------------
sock.on("sendCandidate", (candidate) => {
    console.log("收到candidate信息");
    candidateArr.value.push(candidate);
});

sock.on("addCandidate", async () => {
    for (const candidate of candidateArr.value) {
        await peer.value.addIceCandidate(candidate);
    }
});

// ---------------服务端代码---------------
// 收到candidate
sock.on('sendCandidate', ({ roomId, candidate }) => {
    io.to(roomId).emit('sendCandidate', candidate)
})

// addCandidate
sock.on('addCandidate', roomId => {
    io.to(roomId).emit('addCandidate')
})

5、用户B同上(3、4)

6、接下来用户A和用户B就可以进行P2P通信流

// ---------------前端代码---------------
// 监听onaddstream来获取对方的音视频流
peer.value.onaddstream = (event) => {
    communicating.value = true;
    calling.value = false;

    remoteVideo.value.srcObject = event.stream;
    remoteVideo.value.play();
};

挂断视频

// ---------------前端代码---------------
// 监听P2P连接状态
peer.value.oniceconnectionstatechange = (event) => {
    console.log(event.target.iceConnectionState);
    if (
        event.target.iceConnectionState === "disconnected" ||
        event.target.iceConnectionState === "failed"
    ) {
        close();
    }
};

// 挂断视频
const hangup = () => {
    close()
};

const close = () => {
    peer.value.close();
    caller.value = false;
    called.value = false;
    calling.value = false;
    communicating.value = false;
    localVideo.value.srcObject = null;
    remoteVideo.value.srcObject = null;
    peer.value = null;
    localStream.value.getVideoTracks().forEach((track) => {
        track.stop();
    });
    localStream.value = null;
    candidateArr.value = [];
};

代码

web-web

client

<script setup>
import { ref, onMounted } from "vue";
import { io } from "socket.io-client";

const roomId = "001";
const called = ref(false); // 是否是接收方
const caller = ref(false); // 是否是发起方
const calling = ref(false); // 呼叫中
const communicating = ref(false); //视频通话中
const localVideo = ref(null); // video标签实例,播放本人的视频
const remoteVideo = ref(null); // video标签实例,播放对方的视频
const socket = ref(null);
const peer = ref(null);
const localStream = ref(null);
const candidateArr = ref([]);

onMounted(() => {
    const sock = io("http://localhost:3000");

    sock.on("connectionSuccess", () => {
        console.log("连接成功...");
        // 向服务器发送一个加入
        sock.emit("joinRoom", roomId);
    });

    // 发起方发起视频请求
    sock.on("callRemote", () => {
        // 不是发起方
        if (!caller.value) {
            called.value = true; // 是接收方
            calling.value = true; // 视频待接听
        }
    });

    // 发起方收到接收方同意视频的请求
    sock.on("acceptCall", async () => {
        // 如果是发起方
        if (caller.value) {
            peer.value = new RTCPeerConnection();
            // 添加本地音视频流
            peer.value.addStream(localStream.value);

            // 通过监听onicecandidate事件获取candidate信息
            peer.value.onicecandidate = (event) => {
                if (event.candidate) {
                    socket.value?.emit("sendCandidate", {
                        roomId,
                        candidate: event.candidate,
                    });
                }
            };

            peer.value.oniceconnectionstatechange = (event) => {
                console.log(event.target.iceConnectionState);
                if (
                    event.target.iceConnectionState === "disconnected" ||
                    event.target.iceConnectionState === "failed"
                ) {
                    close();
                }
            };

            // 监听onaddstream来获取对方的音视频流
            peer.value.onaddstream = (event) => {
                communicating.value = true;
                calling.value = false;

                remoteVideo.value.srcObject = event.stream;
                remoteVideo.value.play();
            };

            // 生成offer
            const offer = await peer.value.createOffer({
                offerToReceiveAudio: 1,
                offerToReceiveVideo: 1,
            });

            // 在本地设置offer信息
            await peer.value.setLocalDescription(offer);

            // 发送offer
            socket.value?.emit("sendOffer", { offer, roomId });
        }
    });

    sock.on("sendOffer", async (offer) => {
        // 如果是接收端
        if (called.value) {
            console.log("收到offer");

            // 创建自己的RTCPeerConnection
            peer.value = new RTCPeerConnection();
            // 添加本地音视频流
            const stream = await getLocalStream();
            peer.value.addStream(stream);

            // 通过监听onicecandidate事件获取candidate信息
            peer.value.onicecandidate = (event) => {
                if (event.candidate) {
                    socket.value?.emit("sendCandidate", {
                        roomId,
                        candidate: event.candidate,
                    });
                }
            };

            peer.value.oniceconnectionstatechange = (event) => {
                console.log(event.target.iceConnectionState);
                if (
                    event.target.iceConnectionState === "disconnected" ||
                    event.target.iceConnectionState === "failed"
                ) {
                    close();
                }
            };

            // 监听onaddstream来获取对方的音视频流
            peer.value.onaddstream = (event) => {
                communicating.value = true;
                calling.value = false;

                remoteVideo.value.srcObject = event.stream;
                remoteVideo.value.play();
            };

            // 设置远端描述信息
            await peer.value.setRemoteDescription(offer);
            // 生成answer
            const answer = await peer.value.createAnswer();
            // 在本地设置answer信息
            await peer.value.setLocalDescription(answer);
            // 发送answer
            socket.value?.emit("sendAnswer", { answer, roomId });
        }
    });

    sock.on("sendAnswer", (answer) => {
        // 判断是否是发送方
        if (caller.value) {
            console.log("发送方收到answer");
            // 设置远端answer信息
            peer.value.setRemoteDescription(answer);

            socket.value?.emit("addCandidate", roomId);
        }
    });

    sock.on("sendCandidate", (candidate) => {
        console.log("收到candidate信息");
        candidateArr.value.push(candidate);
    });

    sock.on("addCandidate", async () => {
        for (const candidate of candidateArr.value) {
            await peer.value.addIceCandidate(candidate);
        }
    });

    socket.value = sock;
});

const getLocalStream = async () => {
    // 获取音视频流
    const stream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true,
    });

    localVideo.value.srcObject = stream;
    localVideo.value.play();

    return stream;
};

// 发起方发起视频请求
const callRemote = async () => {
    // 用户A向用户B发起视频请求
    caller.value = true; // 表示当前用户是发起方
    calling.value = true; // 表示是否呼叫中

    localStream.value = await getLocalStream();

    // 通过信令服务器向用户B发起视频请求
    socket.value?.emit("callRemote", roomId);
};

// 接收方同意视频请求
const acceptCall = () => {
    console.log("同意视频邀请");

    // 通过信令服务器通知用户A
    socket.value?.emit("acceptCall", roomId);
};

// 挂断视频
const hangup = () => {
    close()
};

const close = () => {
    peer.value.close();
    caller.value = false;
    called.value = false;
    calling.value = false;
    communicating.value = false;
    localVideo.value.srcObject = null;
    remoteVideo.value.srcObject = null;
    peer.value = null;
    localStream.value.getVideoTracks().forEach((track) => {
        track.stop();
    });
    localStream.value = null;
    candidateArr.value = [];
};
</script>

<template>
    <div>
        <video ref="localVideo"></video>
        <video ref="remoteVideo"></video>

        <button @click="callRemote">发起视频</button>
        <button @click="acceptCall">接收视频</button>
        <button @click="hangup">挂断视频</button>
    </div>
</template>

<style scoped></style>

server

const socket = require('socket.io')
const http = require('http')

const server = http.createServer()

const io = socket(server, {
    cors: {
        origin: '*' // 配置跨域
    }
})

io.on('connection', sock => {
    console.log('连接成功...')
    // 客户端发送就连接成功的消息
    sock.emit('connectionSuccess')

    // 加入房间
    sock.on('joinRoom', roomId => {
        console.log('joinRoom')
        sock.join(roomId)
    })

    // 发起方发起视频请求
    sock.on('callRemote', roomId => {
        console.log('callRemote')
        io.to(roomId).emit('callRemote')
    })

    // 发起方收到接收方同意视频的请求
    sock.on('acceptCall', roomId => {
        console.log('acceptCall')
        io.to(roomId).emit('acceptCall')
    })

    // 接收offer
    sock.on('sendOffer', ({ offer, roomId }) => {
        console.log('sendOffer');
        io.to(roomId).emit('sendOffer', offer)
    })

    // 收到answer
    sock.on('sendAnswer', ({ answer, roomId }) => {
        io.to(roomId).emit('sendAnswer', answer)
    })

    // 收到candidate
    sock.on('sendCandidate', ({ roomId, candidate }) => {
        io.to(roomId).emit('sendCandidate', candidate)
    })

    // addCandidate
    sock.on('addCandidate', roomId => {
        io.to(roomId).emit('addCandidate')
    })
})

server.listen(3000, () => {
    console.log('服务器启动成功')
})

web-app

client-app

/* ---------- hybrid -> html -> local.html ---------- */
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
		<title>视频通话</title>

		<script src="js/vue.js"></script>
		<script type="text/javascript" src="js/uni-webview.js"></script>
	</head>
	<body>
		<div id="app">
			<video ref="localVideo"></video>
			<video ref="remoteVideo"></video>

			<button @click="callRoom">发起呼叫</button>
			<button @click="acceptCall">接听</button>
			<button @click="hangUp">挂断</button>
		</div>

	</body>

	<script>
		new Vue({
			el: "#app",
			data() {
				return {
					called: false,
					caller: false,
					calling: false,
					communicating: false,
					peer: null,
					localStream: null,
					candidateArr: [],
				}
			},
			mounted() {
				window.getUniAppMessage = (arg) => {
					let data = JSON.parse(arg)
					this.handleUniAppMsg(data)
				}
			},
			methods: {
				async handleUniAppMsg(data) {
					if (data.action === 'callRemote') {
						// 不是发起方
						if (!this.caller) {
							this.called = true // 是接收方
							this.calling = true; // 视频待接听
						}
					} else if (data.action === 'acceptCall') {
						// 发起方
						if (this.caller) {
							this.peer = new RTCPeerConnection()

							// 添加本地音视频流
							this.peer.addStream(this.localStream);

							// 通过监听onicecandidate事件获取candidate信息
							this.peer.onicecandidate = (event) => {
								if (event.candidate) {
									uni.postMessage({
										data: {
											action: 'sendCandidate',
											data: {
												candidate: event.candidate
											}
										}
									})
								}
							};

							this.peer.oniceconnectionstatechange = (event) => {
								if (
									event.target.iceConnectionState === "disconnected" ||
									event.target.iceConnectionState === "failed"
								) {
									this.close()
								}
							};

							// 监听onaddstream来获取对方的音视频流
							this.peer.onaddstream = (event) => {
								this.communicating = true;
								this.calling = false

								this.$refs.remoteVideo.srcObject = event.stream
								this.$refs.remoteVideo.play()
							};

							// 生成offer
							const offer = await this.peer.createOffer({
								offerToReceiveAudio: 1,
								offerToReceiveVideo: 1,
							});
							// 在本地设置offer信息
							await this.peer.setLocalDescription(offer)

							uni.postMessage({
								data: {
									action: 'sendOffer',
									data: {
										offer: btoa(JSON.stringify(offer))
									}
								}
							})
						}
					} else if (data.action === 'sendOffer') {
						// 如果是接收端
						if (this.called) {
							console.log("收到offer");

							// 创建自己的RTCPeerConnection
							this.peer = new RTCPeerConnection()
							// 添加本地音视频流
							this.localStream = await this.getLocalStream()

							this.$refs.localVideo.srcObject = this.localStream
							this.$refs.localVideo.play()

							// 添加本地音视频流
							this.peer.addStream(this.localStream)

							// 通过监听onicecandidate事件获取candidate信息
							this.peer.onicecandidate = (event) => {
								if (event.candidate) {
									uni.postMessage({
										data: {
											action: 'sendCandidate',
											data: {
												candidate: event.candidate
											}
										}
									})
								}
							};

							this.peer.oniceconnectionstatechange = (event) => {
								if (
									event.target.iceConnectionState === "disconnected" ||
									event.target.iceConnectionState === "failed"
								) {
									this.close()
								}
							};

							// 监听onaddstream来获取对方的音视频流
							this.peer.onaddstream = (event) => {
								this.communicating = true
								this.calling = false;

								this.$refs.remoteVideo.srcObject = event.stream;
								this.$refs.remoteVideo.play();
							};

							const offer = new RTCSessionDescription(JSON.parse(atob(data.data.offer)))

							// 设置远端描述信息
							await this.peer.setRemoteDescription(offer)
							// 生成answer
							const answer = await this.peer.createAnswer()
							// 在本地设置answer信息
							await this.peer.setLocalDescription(answer)
							// 发送answer
							uni.postMessage({
								data: {
									action: 'sendAnswer',
									data: {
										answer: btoa(JSON.stringify(answer))
									}
								}
							})
						}
					} else if (data.action === 'sendAnswer') {
						// 判断是否是发送方
						if (this.caller) {
							console.log("发送方收到answer");
							const answer = new RTCSessionDescription(JSON.parse(atob(data.data.answer)))

							// 设置远端answer信息
							await this.peer.setRemoteDescription(answer);

							uni.postMessage({
								data: {
									action: 'addCandidate',
									data: {}
								}
							})
						}
					} else if (data.action === 'sendCandidate') {
						console.log("收到candidate信息");
						this.candidateArr.push(data.data.candidate);
					} else if (data.action === 'addCandidate') {
						for (const candidate of this.candidateArr) {
							await this.peer.addIceCandidate(candidate)
						}
					}
				},
				async callRoom() {
					this.caller = true
					this.calling = true

					this.localStream = await this.getLocalStream()

					this.$refs.localVideo.srcObject = this.localStream
					this.$refs.localVideo.play()

					uni.postMessage({
						data: {
							action: 'callRemote',
							data: {}
						}
					})
				},
				acceptCall() {
					console.log("同意视频邀请")

					uni.postMessage({
						data: {
							action: 'acceptCall',
							data: {}
						}
					})
				},
				hangUp() {
					this.close()
				},
				getLocalStream() {
					return new Promise((success, error) => {
						this.getUserMedia({
							audio: true,
							video: {
								width: {
									ideal: 430
								},
								height: {
									ideal: 720
								},
								frameRate: {
									ideal: 30
								}
							}
						}, (res) => {
							success(res)
						}, (err) => {
							error(err)
						})
					})
				},
				close() {
					this.peer.close()
					this.caller = false
					this.called = false
					this.calling = false
					this.communicating = false
					this.$refs.localVideo.srcObject = null
					this.$refs.remoteVideo.srcObject = null
					this.peer = null
					this.localStream.getVideoTracks().forEach(track => {
						track.stop()
					})
					this.localStream = null
					this.candidateArr = []
				},
				getUserMedia(constraints, success, error) {
					if (navigator.mediaDevices.getUserMedia) {
						navigator.mediaDevices.getUserMedia(constraints).then(success).catch(error)
					} else if (navigator.webkitGetUserMedia) {
						// webkit核心浏览器
						navigator.webkitGetUserMedia(constraints, success, error)
					} else if (navigator.mozGetUserMedia) {
						// firfox浏览器
						navigator.mozGetUserMedia(constraints, success, error)
					} else if (navigator.getUserMedia) {
						// 旧版API
						navigator.getUserMedia(constraints, success, error)
					}
				},
			},
		})
	</script>
</html>

/* ---------- pages -> index -> index.html ---------- */
<template>
	<view>
		<web-view src="/hybrid/html/local.html" @message="handleMessage"></web-view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				wv: '',
				socket: null,
				roomId: '001',
				postMessage: () => {},
			}
		},
		onReady() {
			// #ifdef APP-PLUS
			var currentWebView = this.$scope.$getAppWebview()

			setTimeout(() => {
				this.wv = currentWebView.children()[0]
				this.postMsg = this.appSendH5
			}, 300)
			// #endif
		},
		onLoad() {
			this.initSocket()
		},
		methods: {
			handleMessage(e) {
				let data = e.detail.data[0]
				
				if (data.action === 'callRemote') {
					this.socket.send({
						data: JSON.stringify({
							event: 'callRemote',
							roomId: this.roomId
						})
					})
				} else if (data.action === 'acceptCall') {
					this.socket.send({
						data: JSON.stringify({
							event: 'acceptCall',
							roomId: this.roomId
						})
					})
				} else if (data.action === 'sendOffer') {
					this.socket.send({
						data: JSON.stringify({
							event: 'sendOffer',
							roomId: this.roomId,
							offer: data.data.offer
						})
					})
				} else if (data.action === 'sendAnswer') {
					this.socket.send({
						data: JSON.stringify({
							event: 'sendAnswer',
							roomId: this.roomId,
							answer: data.data.answer
						})
					})
				} else if (data.action === 'sendCandidate') {
					this.socket.send({
						data: JSON.stringify({
							event: 'sendCandidate',
							roomId: this.roomId,
							candidate: data.data.candidate,
						})
					})
				} else if (data.action === 'addCandidate') {
					this.socket.send({
						data: JSON.stringify({
							event: 'addCandidate',
							roomId: this.roomId
						})
					})
				}
			},
			initSocket() {
				this.socket = uni.connectSocket({
					url: 'ws://10.3.5.109:3030/',
					success: () => {
						console.log('websocket创建成功');

					},
					fail: () => {
						console.log('websocket创建失败');
					}
				});
				this.bindSocketEvents()
			},
			bindSocketEvents() {
				this.socket.onOpen(() => {
					console.log('websocket连接成功')
					this.socket.send({
						data: JSON.stringify({
							event: 'joinRoom',
							roomId: this.roomId
						})
					})

				})
				this.socket.onMessage(message => {
					const data = JSON.parse(message.data)

					switch (data.event) {
						case 'callRemote':
							console.log('callRemote')
							this.postMsg({
								action: 'callRemote',
								data: {},
							})
							break;
						case 'acceptCall':
							console.log('acceptCall')
							this.postMsg({
								action: 'acceptCall',
								data: {},
							})
							break;
						case 'sendOffer':
							console.log('sendOffer')
							this.postMsg({
								action: 'sendOffer',
								data: {
									offer: data.offer
								},
							})
							break;
						case 'sendAnswer':
							console.log('sendAnswer')
							this.postMsg({
								action: 'sendAnswer',
								data: {
									answer: data.answer
								},
							})
							break;
						case 'sendCandidate':
							console.log('sendCandidate')
							this.postMsg({
								action: 'sendCandidate',
								data: {
									candidate: data.candidate
								},
							})
							break;
						case 'addCandidate':
							console.log('addCandidate')
							this.postMsg({
								action: 'addCandidate',
								data: {},
							})
							break;
						default:
							break;
					}
				})
				this.socket.onError(err => {
					console.log('err',err);
				})
			},
			appSendH5(params) {
				this.wv.evalJS("getUniAppMessage('" + JSON.stringify(params) + "')")
			},
		}
	}
</script>

<style>

</style>

client-web

<script setup>
import { ref, onMounted } from "vue";

const roomId = ref("001");
const called = ref(false); // 是否是接收方
const caller = ref(false); // 是否是发起方
const calling = ref(false); // 呼叫中
const communicating = ref(false); //视频通话中
const localVideo = ref(null); // video标签实例,播放本人的视频
const remoteVideo = ref(null); // video标签实例,播放对方的视频
const ws = ref(null);
const peer = ref(null);
const localStream = ref(null);
const candidateArr = ref([]);

onMounted(() => {});

ws.value = new WebSocket("ws://10.3.5.109:3030");

ws.value.onopen = () => {
    console.log("连接成功");

    ws.value.send(
        JSON.stringify({
            event: "joinRoom",
            roomId: roomId.value,
        })
    );
};

ws.value.onmessage = async (message) => {
    const data = JSON.parse(message.data);

    switch (data.event) {
        case "callRemote":
            // 不是发起方
            if (!caller.value) {
                called.value = true;
                calling.value = true;
            }
            break;
        case "acceptCall":
            // 如果是发起方
            if (caller.value) {
                peer.value = new RTCPeerConnection();
                console.log(localStream.value);
                // 添加本地音视频流
                peer.value.addStream(localStream.value);

                // 通过监听onicecandidate事件获取candidate信息
                peer.value.onicecandidate = (event) => {
                    if (event.candidate) {
                        ws.value.send(
                            JSON.stringify({
                                event: "sendCandidate",
                                roomId: roomId.value,
                                candidate: event.candidate,
                            })
                        );
                    }
                };

                peer.value.oniceconnectionstatechange = (event) => {
                    console.log(event.target.iceConnectionState);
                    if (
                        event.target.iceConnectionState === "disconnected" ||
                        event.target.iceConnectionState === "failed"
                    ) {
                        close();
                    }
                };

                // 监听onaddstream来获取对方的音视频流
                peer.value.onaddstream = (event) => {
                    communicating.value = true;
                    calling.value = false;

                    remoteVideo.value.srcObject = event.stream;
                    remoteVideo.value.play();
                };

                // 生成offer
                const offer = await peer.value.createOffer({
                    offerToReceiveAudio: 1,
                    offerToReceiveVideo: 1,
                });

                // 在本地设置offer信息
                await peer.value.setLocalDescription(offer);

                // 发送offer
                ws.value.send(
                    JSON.stringify({
                        event: "sendOffer",
                        roomId: roomId.value,
                        offer: btoa(JSON.stringify(offer)),
                    })
                );
            }
            break;
        case "sendOffer":
            // 如果是接收端
            if (called.value) {
                console.log("收到offer");

                // 创建自己的RTCPeerConnection
                peer.value = new RTCPeerConnection();
                // 添加本地音视频流
                const stream = await getLocalStream();

                peer.value.addStream(stream);

                // 通过监听onicecandidate事件获取candidate信息
                peer.value.onicecandidate = (event) => {
                    if (event.candidate) {
                        ws.value.send(
                            JSON.stringify({
                                event: "sendCandidate",
                                roomId: roomId.value,
                                candidate: event.candidate,
                            })
                        );
                    }
                };

                peer.value.oniceconnectionstatechange = (event) => {
                    console.log(event.target.iceConnectionState);
                    if (
                        event.target.iceConnectionState === "disconnected" ||
                        event.target.iceConnectionState === "failed"
                    ) {
                        close();
                    }
                };

                // 监听onaddstream来获取对方的音视频流
                peer.value.onaddstream = (event) => {
                    communicating.value = true;
                    calling.value = false;

                    remoteVideo.value.srcObject = event.stream;
                    remoteVideo.value.play();
                };

                const tempOffer = new RTCSessionDescription(JSON.parse(atob(data.offer)));

                // 设置远端描述信息
                await peer.value.setRemoteDescription(tempOffer);
                // 生成answer
                const answer = await peer.value.createAnswer();
                // 在本地设置answer信息
                await peer.value.setLocalDescription(answer);
                // 发送answer
                ws.value.send(
                    JSON.stringify({
                        event: "sendAnswer",
                        roomId: roomId.value,
                        answer: btoa(JSON.stringify(answer)),
                    })
                );
            }
            break;
        case "sendAnswer":
            // 判断是否是发送方
            if (caller.value) {
                console.log("发送方收到answer");

                const tempAnswer = new RTCSessionDescription(
                    JSON.parse(atob(data.answer))
                );

                // 设置远端answer信息
                await peer.value.setRemoteDescription(tempAnswer);

                ws.value.send(
                    JSON.stringify({
                        event: "addCandidate",
                        roomId: roomId.value,
                    })
                );
            }
            break;
        case "sendCandidate":
            console.log("收到candidate信息");
            candidateArr.value.push(data.candidate);
            break;
        case "addCandidate":
            for (const candidate of candidateArr.value) {
                await peer.value.addIceCandidate(candidate);
            }
            break;
        default:
            break;
    }
};

ws.value.onclose = () => {
    // 监听websocket关闭的回调
    console.log("onclose", ws.value.readyState);
};

// 发起方发起视频请求
const callRemote = async () => {
    // 发起方向接收方发起视频请求
    caller.value = true; // 表示当前用户是发起方
    calling.value = true; // 表示是否呼叫中

    localStream.value = await getLocalStream();

    // 通过信令服务器向接收方发起视频请求
    ws.value.send(
        JSON.stringify({
            event: "callRemote",
            roomId: roomId.value,
        })
    );
};

const acceptCall = () => {
    console.log("同意视频邀请");

    // 通过信令服务器通知发送方
    ws.value.send(
        JSON.stringify({
            event: "acceptCall",
            roomId: roomId.value,
        })
    );
};

const hangup = () => {
    close();
};

const close = () => {
    peer.value.close();
    caller.value = false;
    called.value = false;
    calling.value = false;
    communicating.value = false;
    localVideo.value.srcObject = null;
    remoteVideo.value.srcObject = null;
    peer.value = null;
    localStream.value.getVideoTracks().forEach((track) => {
        track.stop();
    });
    localStream.value = null;
    candidateArr.value = [];
};

const getLocalStream = async () => {
    // 获取音视频流
    const stream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: true,
    });

    localVideo.value.srcObject = stream;
    localVideo.value.play();

    return stream;
};
</script>

<template>
    <div>
        <video ref="localVideo"></video>
        <video ref="remoteVideo"></video>

        <button @click="callRemote">发起视频</button>
        <button @click="acceptCall">接收视频</button>
        <button @click="hangup">挂断视频</button>
    </div>
</template>

<style scoped></style>

server

const Websocket = require('ws')

const wss = new Websocket.Server({ port: 3030 })

wss.on('connection', ws => {
    ws.send('123')

    ws.on('message', message => {
        const data = JSON.parse(message.toString())

        switch (data.event) {
            case 'joinRoom':
                // 加入房间
                console.log('加入房间');
                if (typeof ws.roomId === 'undefined' && data.roomId) {
                    ws.roomId = data.roomId
                }
                break;
            case 'callRemote':
                // 向房间发送通话事件
                console.log('向房间发送通话事件');
                wsEmit(ws, {
                    event: 'callRemote',
                })
                break;
            case 'acceptCall':
                // 接受通话事件
                console.log('接受通话事件');
                wsEmit(ws, {
                    event: 'acceptCall',
                })
                break;
            case 'sendOffer':
                // 接收offer
                console.log('接收offer');
                wsEmit(ws, {
                    event: 'sendOffer',
                    offer: data.offer
                })
                break;
            case 'sendAnswer':
                // 接收answer
                console.log('接收answer');
                wsEmit(ws, {
                    event: 'sendAnswer',
                    answer: data.answer
                })
                break;
            case 'sendCandidate':
                // sendCandidate
                console.log('sendCandidate');
                wsEmit(ws, {
                    event: 'sendCandidate',
                    candidate: data.candidate
                })
                break;
            case 'addCandidate':
                // addCandidate
                console.log('addCandidate');
                wsEmit(ws, {
                    event: 'addCandidate'
                })
                break;
            default:
                break;
        }
    })

    // ws.on('close', message => {
    //     console.log('connection closed: ' + message)
    // })
})

// 向房间发送事件
function wsEmit(ws, data) {
    wss.clients.forEach(client => {
        if (client.readyState === Websocket.OPEN && client.roomId === ws.roomId) {
            client.send(JSON.stringify(data))
        }
    })
}

注:代码中只写了接听后通话的挂断,没有写呼叫时的挂断