HTML
页面元素
- 用户列表展示
- 本地video
- 要使用的脚本script
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Example</title>
<style>
.sent{
color:aqua
}
.received{
color:blueviolet
}
</style>
</head>
<body>
<h1>WebRTC Example</h1>
<div class="user-list">
<h3>用户列表</h3>
<ul id="userList"></ul>
</div> <input type="text" id="messageInput" placeholder="发送消息"> <button onclick="sendMessage()">发送</button>
<p>远端消息: <span id="messageResponse"></span></p>
<div style="display: flex; justify-content: space-between;">
<video id="localVideo" autoplay muted playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>
</div>
<script src="app.js"></script>
</body>
</html>
主要逻辑代码
- 首先要使用websocket双方互相交换sdf和ice
const socket = new WebSocket('ws://localhost:8080');
let clientId = Date.now().toString(); // 生成一个唯一的 ID
let roomName = 'default-room'; // 默认房间名称
注意:创建房间为了做进一步的隔离
- 与心灵服务器建立链接的主要事件
socket.onopen = () => {
console.log('Connected to signaling server');
// 注册本地客户端并加入房间
socket.send(JSON.stringify({
type: 'join-room',
id: clientId,
roomName: roomName
}));
};
socket.onmessage = event => {
const data = JSON.parse(event.data);
//如果成功加入房间
if (data.type === 'join-success') {
...
}
//如果加入失败
if (data.type === 'join-failed') {...}
//如果是接收信令服务器的offer
if (data.type === 'offer') {...}
//如果是信令的回答
if (data.type === 'answer') {...}
//如果是信令的ice消息
if (data.type === 'candidate') {...}
}
大体的流程是
- 各自收集音视频流;
//获取本地媒体流并添加到连接
function addLocalStream(peerId,) {
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
localStream=stream
localVideo.srcObject = stream;
})
.catch(error => console.error('Error accessing media devices.', error));
}
- 发起方发起offer;
const conn = new RTCPeerConnection({iceServers});
await conn.createOffer()//创建offer
await conn.setLocalDescription(offer)//设置本地描述
await socket.send(JSON.stringify({//向接收方发送自己offer
type: 'offer',
offer:offer_,
toId: peerId,
roomName: roomName
}));
- 接收方,接收发送方的offer
const conn = new RTCPeerConnection({iceServers});
await conn.setRemoteDescription(offer)//设置远程描述
await let answer =conn.createAnswer()//创建回复
await conn.setLocalDescription(answer)//创建本地描述
await socket.send(JSON.stringify({//将本地回复发送给发起方
type: 'answer',//给发起方
answer: answer,
toId: peerId,
roomName: roomName
}));
- 发起方设置远程描述
conn.setRemoteDescription(data.answer);
当然最重要的ice创建就在发起方和接收方创建各自的new RTCPeerConnection时处理
//将track添加到本地localStream
localStream.getTracks().forEach(track => {
console.log("track", track);
// console.log("stream", stream);
conn.addTrack(track, localStream);
});
// 处理 ICE 候选者
conn.onicecandidate = event => {
console.log("生成ice")
if (event.candidate) {
console.log(`ICE candidate for ${peerId}:`, event.candidate);
socket.send(JSON.stringify({
type: 'candidate',
candidate: event.candidate,
toId: peerId,
roomName: roomName
}));
}
};
//处理接收到的远程流
conn.ontrack = (event) => {
console.log("处理接收远处的流", event);
// 使用 event.streams[0] 来获取完整的流
const stream = event.streams[0];
// 如果已经存在视频元素,直接更新流
if (remoteVideos[peerId]) {
const videoElement = remoteVideos[peerId];
videoElement.srcObject = stream;
// Optional: 更新其他属性,比如设置为 Playing
} else {
// 根据规范,可以直接访问 event.track,但 event.streams 是 best practice
const videoElement = document.createElement('video');
videoElement.id = `remoteVideo-${peerId}`;
videoElement.autoplay = true;
videoElement.playsInline = true;
videoElement.srcObject = stream; // 绑定流到 video 元素
// 将视频元素添加到页面中
document.body.appendChild(videoElement);
// 存储远程视频元素,方便后续操作
remoteVideos[peerId] = videoElement;
// 监听媒体流结束
stream.onended = () => {
// 当媒体流结束时,移除对应的视频元素
videoElement.remove();
delete remoteVideos[peerId];
};
}
};
- 各自交换ice
if (data.type === 'candidate') {
const conn = localPc
console.log("是否有远端的conn",conn)
console.log("ice",data.candidate)
if (conn) {
console.log("交换开始")
conn.addIceCandidate(data.candidate
);
}
}
6.发起发要创建信息通道用于文字,文件,图片等的交流,也是在创建new RTCPeerConnection时处理
// 发起方创建数据通道并存储
const localChannel= conn.createDataChannel('faqiChannel', {
ordered: true,
protocol: "json",
});
setupChannelEvents(localChannel, peerId);
dataChannelMap.set(peerId,localChannel) // 存储到对应 peerId 的通道
接收方可以在设置远程描述时找到发起方创建的通道相关信息,在ice交换完可利用ondatachannel监听通道信息
// 接收方通过事件获取对方创建的通道
localPc.ondatachannel = (event) => {
const receiveChannel = event.channel;
setupChannelEvents(receiveChannel, peerId);
dataChannelMap.set(peerId,receiveChannel) // 存储到对应 peerId 的通道
} ;
信令服务端代码如下:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
console.log('WebSocket server started on port 8080');
// 房间管理,结构为 Map<roomName, Map<peerId, WebSocket>>
const rooms = new Map();
wss.on('connection', ws => {
let currentId = Date.now().toString(); // 临时 ID
let currentRoom = null;
ws.on('message', message => {
console.log(`Received message from ${currentId}: ${message}`);
try {
const data = JSON.parse(message);
if (data.type === 'join-room') {
// 客户端加入房间
const roomName = data.roomName;
const peerId = data.id;
// 创建房间(如果不存在)
if (!rooms.has(roomName)) {
rooms.set(roomName, new Map());
}
// 加入房间
const room = rooms.get(roomName);
if (room.has(peerId)) {
ws.send(JSON.stringify({
type: 'join-failed',
message: 'Peer ID already exists in the room',
roomName
}));
return;
}
// 更新客户端 ID 和房间
room.set(peerId, ws);
currentId = peerId;
currentRoom = roomName;
// // 告知其他成员新成员加入
const peers = Array.from(room.keys()).filter(key => key !== peerId);
// room.forEach((clientWs, clientPeerId) => {
// if (clientPeerId !== peerId) {
// clientWs.send(JSON.stringify({
// type: 'new-peer',
// peerId: peerId,
// roomName
// }));
// }
// });
// 发送成功消息
ws.send(JSON.stringify({
type: 'join-success',
id: peerId,
peers: peers,
roomName
}));
console.log(`Client ${peerId} joined room ${roomName}`);
} else if (data.type === 'offer') {
console.log("转发Offer")
console.log("当前currentId",currentId)
console.log(data.offer)
// 转发 Offer
const toId = data.toId;
const room = rooms.get(currentRoom);
const peers = Array.from(room.keys()).filter(key => key !== toId);
console.log("来自toId",toId)
if (room) {
const toWs = room.get(toId);
if (toWs) {
toWs.send(JSON.stringify({
type: 'offer',
offer: data.offer,
fromId: currentId,
roomName: currentRoom,
peers:peers
}));
} else {
console.error(`Remote peer ${toId} not found in room ${currentRoom}`);
}
}
} else if (data.type === 'answer') {
console.log("转发Answer")
// 转发 Answer
const toId = data.toId;
const room = rooms.get(currentRoom);
if (room) {
const toWs = room.get(toId);
if (toWs) {
toWs.send(JSON.stringify({
type: 'answer',
answer: data.answer,
fromId: currentId,
roomName: currentRoom
}));
} else {
console.error(`Remote peer ${toId} not found in room ${currentRoom}`);
}
}
} else if (data.type === 'candidate') {
console.log("转发Candidate")
// 转发 ICE Candidate
const toId = data.toId;
const room = rooms.get(currentRoom);
if (room) {
const toWs = room.get(toId);
if (toWs) {
toWs.send(JSON.stringify({
type: 'candidate',
candidate: data.candidate,
fromId: currentId,
roomName: currentRoom
}));
} else {
console.error(`Remote peer ${toId} not found in room ${currentRoom}`);
}
}
}
} catch (error) {
console.error('Error processing message:', error);
}
});
ws.on('close', () => {
if (currentRoom) {
const room = rooms.get(currentRoom);
if (room) {
room.delete(currentId);
console.log(`Client ${currentId} left room ${currentRoom}`);
// 通知其他成员该用户已离开
room.forEach((clientWs) => {
clientWs.send(JSON.stringify({
type: 'peer-disconnected',
peerId: currentId,
roomName: currentRoom,
}));
});
}
}
});
});
现在就实现了一对一的视频交流,如果同学们想要深入了解原理,可以去看看SCTP协议。
总结,websocket就是个转发的服务器,一旦webrtc ice交换完成通道建立及可基于webrtc信息交换;