24、项目实战:视频会议系统(三):音视频通话核心逻辑(加入/离开房间、媒体流管理)、屏幕共享与录制功能
好,我们接着往下走。上一章我们把信令和房间的概念搭好了,这一章要动真格的了——把音视频流真正跑起来。说白了,就是用户点“加入房间”之后,摄像头怎么开、麦克风怎么采、别人的画面怎么显示、人走了怎么清理。这些逻辑,是视频会议最核心的部分。
我个人习惯把这一块分成三个大块:房间的生命周期管理(加入/离开)、媒体流的收发与渲染、以及屏幕共享与录制。咱们一个一个来拆。
一、加入与离开房间:PeerConnection 的建立与销毁
加入房间,本质上就是跟房间里的每一个人建立一条 PeerConnection。离开房间,就是把所有 PeerConnection 关掉,并通知其他人。
我在项目中遇到过一个问题:用户快速刷新页面,结果服务端还留着旧的 PeerConnection,导致新连接一直建不起来。后来我加了一个“离开房间”的显式清理流程,才彻底解决。
1.1 加入房间的核心流程
当用户点击“加入”按钮,我们需要做这几件事:
- 获取本地音视频流(getUserMedia)
- 创建本地 PeerConnection 工厂(RTCPeerConnection)
- 通过信令服务器发送“join”消息
- 收到服务端返回的“已有用户列表”
- 对每个已有用户,创建 PeerConnection,并发起 offer
- 监听新用户加入事件,同样创建 PeerConnection
代码骨架大概是这样:
// 加入房间
async function joinRoom(roomId) {
// 1. 获取本地流
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById('localVideo').srcObject = localStream;
// 2. 连接信令
socket.emit('join', { roomId, userId: myUserId });
// 3. 监听已有用户
socket.on('existingUsers', (users) => {
users.forEach(user => {
createPeerConnection(user.userId, true); // 主动发起offer
});
});
// 4. 监听新用户
socket.on('userJoined', (user) => {
createPeerConnection(user.userId, false); // 等待对方offer
});
}
1.2 离开房间的清理工作
离开房间时,很多人只关了摄像头,但 PeerConnection 还在后台跑着。这会导致内存泄漏,而且对方会一直显示“连接中”。
正确的做法是:
- 遍历所有 PeerConnection,调用 close()
- 将 remoteStream 的 srcObject 置空
- 停止本地 track(可选,但建议做)
- 通知信令服务器:我走了
function leaveRoom() {
// 关闭所有 PeerConnection
peerConnections.forEach(pc => pc.close());
peerConnections.clear();
// 停止本地流
localStream.getTracks().forEach(track => track.stop());
localStream = null;
// 清理远程视频元素
document.querySelectorAll('.remote-video').forEach(el => el.srcObject = null);
// 通知服务端
socket.emit('leave', { roomId, userId: myUserId });
}
二、媒体流管理:谁在说话?谁的视频卡了?
媒体流管理,说白了就是“谁发了什么流,我怎么展示”。在多人会议里,你可能会收到 5 个、10 个甚至更多人的音视频流。怎么管理这些流?
我建议用一个 Map 来存储:key 是 userId,value 是 { pc, stream, videoElement }。这样增删改查都很方便。
2.1 流的添加与渲染
当 PeerConnection 触发 ontrack 事件时,我们拿到对方的 MediaStream。然后把它挂到一个 video 元素上。
pc.ontrack = (event) => {
const remoteStream = event.streams[0];
const videoEl = document.createElement('video');
videoEl.srcObject = remoteStream;
videoEl.autoplay = true;
videoEl.playsInline = true;
document.getElementById('remoteVideos').appendChild(videoEl);
// 存起来
remoteStreams.set(userId, { stream: remoteStream, element: videoEl });
};
你想想看,如果房间里有 10 个人,你就得动态创建 10 个 video 标签。布局上可以用 grid 或者 flex 自动排列。我个人习惯用 CSS grid,设置每行最多 4 个,超过就换行。
2.2 音频的自动播放策略
这里有个很烦人的问题:浏览器为了用户体验,不允许自动播放带声音的视频。如果你直接把 remoteStream 挂到 video 上,可能只有画面没有声音。
解决办法:在用户第一次点击“加入房间”时,先调用一次 audioContext.resume(),或者给 video 元素加 muted 属性,等用户交互后再取消静音。
await audioCtx.resume(),问题解决。
三、屏幕共享:把桌面变成第二个摄像头
屏幕共享,本质上就是用 getDisplayMedia 替换掉本地的视频 track。注意,音频 track 不能从屏幕共享里获取,所以我们需要单独处理。
3.1 启动屏幕共享
async function startScreenShare() {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false // 屏幕共享不包含系统音频
});
// 替换本地视频 track
const videoTrack = screenStream.getVideoTracks()[0];
const sender = peerConnections.get(remoteUserId).getSenders().find(s => s.track.kind === 'video');
await sender.replaceTrack(videoTrack);
// 监听用户停止共享(点击浏览器自带的“停止共享”按钮)
videoTrack.onended = () => {
stopScreenShare();
};
} catch (err) {
console.error('屏幕共享失败:', err);
}
}
这里要注意:replaceTrack 是替换 track,而不是重新 addTrack。这样其他用户不需要重新建立连接,画面就会自动切换。
3.2 停止屏幕共享
停止共享时,我们需要把本地摄像头的 track 换回来。
async function stopScreenShare() {
const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const videoTrack = cameraStream.getVideoTracks()[0];
const sender = peerConnections.get(remoteUserId).getSenders().find(s => s.track.kind === 'video');
await sender.replaceTrack(videoTrack);
// 停止屏幕共享流
screenStream.getTracks().forEach(track => track.stop());
}
四、录制功能:把会议存下来
录制功能,我用的是 MediaRecorder API。它可以把 MediaStream 直接录成 blob,然后下载或者上传到服务器。
4.1 录制本地流或远程流
你可以选择录制“本地摄像头+麦克风”,也可以录制“整个会议画面”(比如把多个 remote video 合成一个 canvas 再录)。这里我讲最简单的:录制本地流。
let mediaRecorder;
let recordedChunks = [];
function startRecording(stream) {
recordedChunks = [];
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm; codecs=vp9' // 浏览器支持的话
});
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `meeting-${Date.now()}.webm`;
a.click();
};
mediaRecorder.start(1000); // 每秒生成一个 chunk
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
}
4.2 录制整个会议画面
如果你想录制“画中画”效果,需要把所有 remote video 画到一个 canvas 上,然后用 canvas.captureStream() 获取合成流。
const canvas = document.getElementById('meetingCanvas');
const ctx = canvas.getContext('2d');
// 每帧绘制所有视频
function drawMeeting() {
ctx.drawImage(localVideo, 0, 0, 200, 150);
remoteVideos.forEach((video, index) => {
const x = (index % 4) * 200;
const y = Math.floor(index / 4) * 150;
ctx.drawImage(video, x, y, 200, 150);
});
requestAnimationFrame(drawMeeting);
}
// 获取合成流
const mixedStream = canvas.captureStream(30); // 30fps
startRecording(mixedStream);
五、整体架构图
下面这张图,把本章的核心逻辑串起来了。你可以看到从加入房间到媒体流管理,再到屏幕共享和录制,整个流程是环环相扣的。
六、避坑总结
最后,我把这一章容易踩的坑列一下,都是我用真金白银换来的经验:
| 场景 | 坑 | 解决办法 |
|---|---|---|
| 加入房间 | 重复创建 PeerConnection | 用 Map 缓存,先检查是否存在 |
| 离开房间 | 摄像头灯不灭 | 显式调用 track.stop() 并置空 stream |
| 屏幕共享 | 停止共享后画面黑屏 | 用 replaceTrack 切回摄像头 track |
| 录制 | 录出来的文件没有声音 | 确保 MediaRecorder 的 mimeType 支持音频编码 |
嗯,这一章的内容就到这里。代码量虽然不大,但每一行都关系到用户体验。你写的时候,多想想“如果用户网络断了怎么办”、“如果用户点了两次加入怎么办”。把这些边界情况处理好,你的视频会议系统才算真正能用。