手把手教你实现 WebRTC 视频通话

使用阿里的Lingma生成的WebRTC视频对话代码,本地测试成功,记录一下代码

  • 注意:需要生成本地的https证书的文件,因为视频流需要https才能测试

  • 后端使用的node

  • 启动命令

1
node server.js

以下是核心代码记录

1.server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
const WebSocket = require('ws');
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');

// 检查是否存在SSL证书文件,如果存在则创建HTTPS服务器
let httpsServer;
try {
const options = {
key: fs.readFileSync(path.join(__dirname, 'key.pem')),
cert: fs.readFileSync(path.join(__dirname, 'cert.pem'))
};
httpsServer = https.createServer(options, (req, res) => {
// 提供index.html文件
if (req.url === '/' || req.url === '/index.html') {
fs.readFile(path.join(__dirname, 'index.html'), (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data);
});
}
// 提供script.js文件
else if (req.url === '/script.js') {
fs.readFile(path.join(__dirname, 'script.js'), (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(data);
});
}
// 其他请求返回404
else {
res.writeHead(404);
res.end('Not Found');
}
});
console.log('HTTPS服务器可用');
} catch (err) {
console.log('未找到SSL证书文件,仅提供HTTP服务');
}

// 创建HTTP服务器来提供静态文件
const server = http.createServer((req, res) => {
// 提供index.html文件
if (req.url === '/' || req.url === '/index.html') {
fs.readFile(path.join(__dirname, 'index.html'), (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data);
});
}
// 提供script.js文件
else if (req.url === '/script.js') {
fs.readFile(path.join(__dirname, 'script.js'), (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not Found');
return;
}
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(data);
});
}
// 其他请求返回404
else {
res.writeHead(404);
res.end('Not Found');
}
});

// 创建WebSocket服务器(支持HTTP和HTTPS)
const wss = new WebSocket.Server({
server: httpsServer ? httpsServer : server
});

// 存储房间信息
const rooms = new Map();

wss.on('connection', (ws) => {
console.log('新客户端连接');

ws.on('message', (message) => {
try {
const data = JSON.parse(message);
handleSignalingMessage(ws, data);
} catch (error) {
console.error('处理消息时出错:', error);
}
});

ws.on('close', () => {
console.log('客户端断开连接');
// 清理房间信息
for (const [roomId, clients] of rooms.entries()) {
if (clients.has(ws)) {
clients.delete(ws);
// 通知房间内其他用户
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'leave',
roomId: roomId
}));
}
});
// 如果房间为空,删除房间
if (clients.size === 0) {
rooms.delete(roomId);
}
break;
}
}
});
});

// 处理信令消息
function handleSignalingMessage(ws, data) {
const { type, roomId } = data;

// 确保房间存在
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}

const room = rooms.get(roomId);

switch (type) {
case 'join':
// 将客户端添加到房间
room.add(ws);
console.log(`客户端加入房间 ${roomId},房间内共有 ${room.size} 个客户端`);

// 如果房间内有两人,通知双方可以开始通话
if (room.size === 2) {
console.log('房间已满,通知双方准备通话');
room.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'ready',
roomId: roomId
}));
}
});
}
// 如果房间已满(超过2人)
else if (room.size > 2) {
console.log('房间已满,拒绝新客户端加入');
ws.send(JSON.stringify({
type: 'full',
roomId: roomId
}));
room.delete(ws);
}
break;

case 'offer':
case 'answer':
case 'candidate':
console.log(`转发 ${type} 消息给房间 ${roomId} 的其他客户端`);
// 转发消息给房间内其他客户端
let recipientCount = 0;
room.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
recipientCount++;
}
});
console.log(`已转发给 ${recipientCount} 个客户端`);
break;
}
}

const PORT = process.env.PORT || 8087;
server.listen(PORT, '0.0.0.0', () => {
const interfaces = require('os').networkInterfaces();
console.log(`HTTP服务器运行在端口 ${PORT}`);
console.log('可以通过以下地址访问:');
console.log(` 本地: http://localhost:${PORT}`);

// 打印所有可用的内网IP地址
Object.keys(interfaces).forEach(interfaceName => {
interfaces[interfaceName].forEach(interface => {
if (interface.family === 'IPv4' && !interface.internal) {
console.log(` 内网: http://${interface.address}:${PORT}`);
}
});
});

// 如果HTTPS服务器可用,也启动它
if (httpsServer) {
const SSL_PORT = process.env.SSL_PORT || 8443;
httpsServer.listen(SSL_PORT, '0.0.0.0', () => {
console.log(`HTTPS服务器运行在端口 ${SSL_PORT}`);
console.log('可以通过以下地址访问:');
console.log(` 本地: https://localhost:${SSL_PORT}`);

// 打印所有可用的内网IP地址
Object.keys(interfaces).forEach(interfaceName => {
interfaces[interfaceName].forEach(interface => {
if (interface.family === 'IPv4' && !interface.internal) {
console.log(` 内网: https://${interface.address}:${SSL_PORT}`);
}
});
});
});
}
});

2.index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#4CAF50">
<title>WebRTC 视频通话</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
background-color: #f0f0f0;
margin: 0;
padding: 0;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
video {
width: 45%;
height: 300px;
margin: 10px;
background-color: #000;
border-radius: 5px;
}
#localVideo {
transform: scaleX(-1);
}
.controls {
margin: 20px 0;
}
button {
padding: 10px 20px;
margin: 5px;
font-size: 16px;
cursor: pointer;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
}
button:hover {
background-color: #45a049;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
#roomId {
padding: 10px;
font-size: 16px;
width: 200px;
border: 1px solid #ccc;
border-radius: 5px;
}

.notification {
background-color: #ffeb3b;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
display: none;
}

@media (max-width: 768px) {
video {
width: 90%;
height: 200px;
margin: 5px 0;
}

.container {
padding: 10px;
}

#roomId {
width: 150px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>WebRTC 视频通话</h1>

<div id="httpsNotification" class="notification">
<p>注意:为了在移动设备上正常使用摄像头和麦克风,请使用HTTPS协议访问此页面。</p>
<p>当前协议: <span id="currentProtocol"></span></p>
</div>

<div>
<input type="text" id="roomId" placeholder="输入房间号" />
<button id="joinBtn">加入房间</button>
<button id="leaveBtn" disabled>离开房间</button>
</div>

<div class="controls">
<button id="startBtn">开始视频</button>
<button id="callBtn" disabled>呼叫</button>
</div>

<div>
<video id="localVideo" autoplay playsinline muted></video>
<video id="remoteVideo" autoplay playsinline></video>
</div>

<div>
<p>本地视频</p>
<p>远程视频</p>
</div>
</div>

<script>
// 检查是否使用HTTPS协议,如果不是则显示提示
document.addEventListener('DOMContentLoaded', function() {
const protocol = window.location.protocol;
document.getElementById('currentProtocol').textContent = protocol;

if (protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
document.getElementById('httpsNotification').style.display = 'block';
}
});
</script>

<script src="script.js"></script>
</body>
</html>

3.script.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
class VideoChat {
constructor() {
// DOM元素
this.localVideo = document.getElementById('localVideo');
this.remoteVideo = document.getElementById('remoteVideo');
this.startBtn = document.getElementById('startBtn');
this.callBtn = document.getElementById('callBtn');
this.joinBtn = document.getElementById('joinBtn');
this.leaveBtn = document.getElementById('leaveBtn');
this.roomIdInput = document.getElementById('roomId');

// WebRTC相关变量
this.localStream = null;
this.remoteStream = null;
this.peerConnection = null;
this.socket = null;
this.roomId = null;

// ICE服务器配置
this.configuration = {
iceServers: [
{
urls: [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302'
]
}
]
};

// 绑定事件
this.bindEvents();
}

bindEvents() {
this.startBtn.addEventListener('click', () => this.startLocalVideo());
this.callBtn.addEventListener('click', () => this.callUser());
this.joinBtn.addEventListener('click', () => this.joinRoom());
this.leaveBtn.addEventListener('click', () => this.leaveRoom());
}

// 获取本地视频流
async startLocalVideo() {
try {
const constraints = {
video: {
facingMode: 'user', // 优先使用前置摄像头
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: true
};

this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
this.localVideo.srcObject = this.localStream;
this.startBtn.disabled = true;
this.callBtn.disabled = false;
console.log('成功获取本地媒体流');
} catch (error) {
console.error('获取本地媒体流失败:', error);
let errorMessage = '无法访问摄像头或麦克风,请检查权限设置';

// 根据错误类型提供更具体的错误信息
if (error.name === 'NotAllowedError') {
errorMessage = '用户拒绝了摄像头或麦克风访问权限,请在浏览器设置中允许访问';
} else if (error.name === 'NotFoundError') {
errorMessage = '未找到可用的摄像头或麦克风设备';
} else if (error.name === 'NotReadableError') {
errorMessage = '摄像头或麦克风正被其他应用占用';
} else if (error.name === 'OverconstrainedError') {
errorMessage = '摄像头不支持请求的分辨率或其他参数';
} else if (error.name === 'SecurityError') {
if (location.protocol !== 'https:') {
errorMessage = '当前页面不安全,无法访问摄像头或麦克风。请使用HTTPS访问';
}
}

alert(errorMessage);
}
}

// 加入房间
joinRoom() {
const roomId = this.roomIdInput.value.trim();
if (!roomId) {
alert('请输入房间号');
return;
}

this.roomId = roomId;
console.log('正在加入房间:', roomId);

// 连接信令服务器 - 使用当前页面的协议和主机地址
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.hostname || 'localhost';
const wsPort = window.location.port ? window.location.port : (window.location.protocol === 'https:' ? '443' : '80');
const wsUrl = `${protocol}//${wsHost}:${wsPort}`;

console.log('尝试连接WebSocket服务器:', wsUrl);
this.socket = new WebSocket(wsUrl);

this.socket.onopen = () => {
console.log('[' + new Date().toISOString() + '] 成功连接到信令服务器');
console.log('发送加入房间请求:', { type: 'join', roomId: this.roomId });
// 发送加入房间消息
this.sendMessage({
type: 'join',
roomId: this.roomId
});
};

this.socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[' + new Date().toISOString() + '] 收到消息:', data);
this.handleSignalingMessage(data);
} catch (error) {
console.error('[' + new Date().toISOString() + '] 解析消息失败:', error, '原始数据:', event.data);
}
};

this.socket.onclose = (event) => {
console.log('[' + new Date().toISOString() + '] 与信令服务器断开连接:', event);
if (event.wasClean) {
console.log(`[${new Date().toISOString()}] 干净的关闭, 状态码: ${event.code}, 原因: ${event.reason}`);
} else {
console.warn(`[${new Date().toISOString()}] 意外关闭, 状态码: ${event.code}, 原因: ${event.reason}`);
}
};

this.socket.onerror = (error) => {
console.error('[' + new Date().toISOString() + '] WebSocket连接错误:', error);
alert('连接信令服务器失败,请检查网络连接\n错误: ' + error.message);
// 清理socket以避免残留
if (this.socket) {
this.socket.onopen = this.socket.onmessage = this.socket.onclose = this.socket.onerror = null;
this.socket.close();
this.socket = null;
}
};

this.joinBtn.disabled = true;
this.leaveBtn.disabled = false;
}

// 离开房间
leaveRoom() {
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}

if (this.socket) {
this.socket.close();
this.socket = null;
}

if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}

this.remoteVideo.srcObject = null;
this.localVideo.srcObject = null;

this.joinBtn.disabled = false;
this.leaveBtn.disabled = true;
this.startBtn.disabled = false;
this.callBtn.disabled = true;
}

// 呼叫用户
callUser() {
console.log('[' + new Date().toISOString() + '] 发起呼叫');

if (!this.localStream) {
console.error('[' + new Date().toISOString() + '] 无法发起呼叫:本地流未就绪');
alert('无法发起呼叫:本地视频流未就绪');
return;
}

// 创建RTCPeerConnection
this.createPeerConnection();

// 添加本地流到连接
this.localStream.getTracks().forEach(track => {
console.log('[' + new Date().toISOString() + '] 添加本地轨道:', track.kind, 'id:', track.id);
this.peerConnection.addTrack(track, this.localStream);
});

// 创建offer
this.peerConnection.createOffer()
.then(offer => {
console.log('[' + new Date().toISOString() + '] 创建offer成功:', offer);
return this.peerConnection.setLocalDescription(offer);
})
.then(() => {
console.log('[' + new Date().toISOString() + '] 设置本地描述成功:', this.peerConnection.localDescription);
// 发送offer到信令服务器
this.sendMessage({
type: 'offer',
roomId: this.roomId,
offer: this.peerConnection.localDescription
});
})
.catch(error => {
console.error('[' + new Date().toISOString() + '] 创建offer失败:', error);
alert('建立连接失败,请重试\n错误: ' + error.message);
});
}

// 创建RTCPeerConnection
createPeerConnection() {
console.log('[' + new Date().toISOString() + '] 创建RTCPeerConnection,配置:', this.configuration);
this.peerConnection = new RTCPeerConnection(this.configuration);

// 处理ICE候选
this.peerConnection.onicecandidate = (event) => {
if (event.candidate) {
console.log('[' + new Date().toISOString() + '] 发现ICE候选:', event.candidate);
this.sendMessage({
type: 'candidate',
roomId: this.roomId,
candidate: event.candidate
});
} else {
console.log('[' + new Date().toISOString() + '] ICE收集完成');
}
};

// 处理远程流
this.peerConnection.ontrack = (event) => {
console.log('[' + new Date().toISOString() + '] 收到远程轨道:', event.track.kind, 'id:', event.track.id);
console.log('[' + new Date().toISOString() + '] 流信息:', event.streams[0]);
this.remoteVideo.srcObject = event.streams[0];
};

// 处理连接状态变化
this.peerConnection.onconnectionstatechange = () => {
const state = this.peerConnection.connectionState;
console.log('[' + new Date().toISOString() + '] 连接状态变化:', state);

switch (state) {
case 'new':
console.log('[' + new Date().toISOString() + '] 连接新建');
break;
case 'connecting':
console.log('[' + new Date().toISOString() + '] 正在连接');
break;
case 'connected':
console.log('[' + new Date().toISOString() + '] 连接成功建立');
alert('连接已建立,可以开始视频通话');
break;
case 'disconnected':
console.log('[' + new Date().toISOString() + '] 连接断开');
break;
case 'failed':
console.error('[' + new Date().toISOString() + '] 连接失败');
alert('连接失败,请检查网络或重试');
break;
case 'closed':
console.log('[' + new Date().toISOString() + '] 连接已关闭');
break;
}
};

// 处理ICE连接状态变化
this.peerConnection.oniceconnectionstatechange = () => {
const state = this.peerConnection.iceConnectionState;
console.log('[' + new Date().toISOString() + '] ICE连接状态变化:', state);

switch (state) {
case 'new':
console.log('[' + new Date().toISOString() + '] ICE连接新建');
break;
case 'checking':
console.log('[' + new Date().toISOString() + '] ICE连接检查中');
break;
case 'connected':
console.log('[' + new Date().toISOString() + '] ICE连接已连接');
break;
case 'completed':
console.log('[' + new Date().toISOString() + '] ICE连接完成');
break;
case 'disconnected':
console.log('[' + new Date().toISOString() + '] ICE连接断开');
break;
case 'failed':
console.error('[' + new Date().toISOString() + '] ICE连接失败');
break;
case 'closed':
console.log('[' + new Date().toISOString() + '] ICE连接已关闭');
break;
}
};
}

// 处理信令消息
handleSignalingMessage(message) {
console.log('处理信令消息:', message);
switch (message.type) {
case 'offer':
this.handleOffer(message.offer);
break;
case 'answer':
this.handleAnswer(message.answer);
break;
case 'candidate':
this.handleCandidate(message.candidate);
break;
case 'ready':
// 另一个用户已准备就绪,可以发起呼叫
console.log('收到ready消息,可以发起呼叫');
this.callBtn.disabled = false;
// 如果本地流已经准备好了,自动发起呼叫
if (this.localStream) {
console.log('本地流已准备好,自动发起呼叫');
setTimeout(() => {
this.callUser();
}, 1000);
}
break;
case 'full':
alert('房间已满');
this.leaveRoom();
break;
case 'leave':
console.log('对方已离开房间');
alert('对方已离开房间');
this.leaveRoom();
break;
}
}

// 处理收到的offer
async handleOffer(offer) {
console.log('收到offer:', offer);
if (!this.peerConnection) {
this.createPeerConnection();

// 添加本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
console.log('添加本地轨道到连接:', track.kind);
this.peerConnection.addTrack(track, this.localStream);
});
} else {
console.error('本地流未准备好');
}
}

try {
await this.peerConnection.setRemoteDescription(offer);
console.log('已设置远程offer描述');

// 创建answer
const answer = await this.peerConnection.createAnswer();
console.log('创建answer成功:', answer);

await this.peerConnection.setLocalDescription(answer);
console.log('已设置本地answer描述');

// 发送answer
this.sendMessage({
type: 'answer',
roomId: this.roomId,
answer: this.peerConnection.localDescription
});
} catch (error) {
console.error('处理offer时出错:', error);
}
}

// 处理收到的answer
async handleAnswer(answer) {
console.log('收到answer:', answer);
try {
await this.peerConnection.setRemoteDescription(answer);
console.log('已设置远程answer描述');
} catch (error) {
console.error('处理answer时出错:', error);
}
}

// 处理收到的ICE候选
async handleCandidate(candidate) {
console.log('收到ICE候选:', candidate);
if (this.peerConnection) {
try {
await this.peerConnection.addIceCandidate(candidate);
console.log('已添加ICE候选');
} catch (error) {
console.error('添加ICE候选时出错:', error);
}
} else {
console.warn('收到ICE候选但连接未建立');
}
}

// 发送消息到信令服务器
sendMessage(message) {
if (!message) {
console.warn('[' + new Date().toISOString() + '] 尝试发送空消息');
return;
}

if (this.socket && this.socket.readyState === WebSocket.OPEN) {
const messageStr = JSON.stringify(message);
console.log('[' + new Date().toISOString() + '] 发送消息:', message);
this.socket.send(messageStr);
} else {
console.warn('[' + new Date().toISOString() + '] 无法发送消息,WebSocket未打开,状态:',
this.socket ? this.socket.readyState : '无socket');
}
}
}

// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', () => {
new VideoChat();
});