继上篇文章,通过Mediasoup+WebRTC实现网页之间的多对多关系传输功能的实现
我们将选择业界最流行、性能最高的开源SFU之一——Mediasoup来构建。选择它的原因:
-
高性能: 采用C++编写核心媒体处理逻辑。
-
灵活性: 通过Node.js模块进行控制,可以和我们现有的信令服务器无缝集成。
-
现代设计: API设计优秀,概念清晰。
项目架构与总览
我们的新系统将由三部分组成,运行在同一台服务器上(为了简化):
-
服务器应用 (Server App):
-
这是一个Node.js应用。
-
它内部包含了两个角色:
-
信令服务器 (Signaling): 负责处理WebSocket连接,转发信令。我们将改造现有的server.js。
-
媒体控制器 (Media Controller): 负责创建和管理Mediasoup的媒体资源(Worker, Router, Transport等)。
-
-
-
Mediasoup C++进程 (Worker):
-
这是媒体处理的核心,负责接收、转发音视频流。
-
我们的Node.js应用会启动并控制这些C++进程。我们不需要直接编写C++代码。
-
-
客户端 (Client):
-
APP端(WEB端)和WEB端。
-
需要改造,使用mediasoup-client库来与SFU进行交互。
-
第一步:环境准备与服务器改造
目标: 搭建一个集成了Mediasoup的Node.js服务器。
1. 环境要求
Mediasoup的C++部分需要编译,对环境有一定要求:
-
Python 3.x
-
GNU Make 和一个 C++17 编译器 (如 GCC 8.x+ 或 Clang 7+)
-
在Windows上: 最简单的方式是安装 Visual Studio 2019 (or newer) Build Tools,并确保勾选了 "Desktop development with C++" 工作负载。
-
在Linux (Ubuntu/Debian)上: sudo apt-get install -y python3 make g++
-
在macOS上: 安装 Xcode Command Line Tools 即可。
-
2. 改造现有服务器项目
我们回到之前的 signaling-server 目录。
-
安装Mediasoup:
codeBash
在signaling-server目录下,运行命令安装Mediasoup。这个过程会自动编译C++部分,所以会比较慢,请耐心等待。npm install mediasoup@3 -
更新package.json:
你的dependencies现在应该包含了mediasoup。 -
创建新的服务器主文件 app.js:
我们将创建一个新的主文件app.js,它将包含信令逻辑和Mediasoup控制逻辑。原来的server.js可以先留着备用或删除。在 signaling-server 目录下创建 app.js,并粘贴以下骨架代码。这是我们服务器的全新起点。
codeJavaScript// app.js const express = require('express'); const http = require('http'); const { Server } = require("socket.io"); const mediasoup = require('mediasoup'); const config = require('./config'); // 我们将把配置分离出去 // --- 全局变量 --- let worker; // Mediasoup C++ Worker let router; // Mediasoup Router const rooms = {}; // 用于存储所有房间信息 // --- 初始化 Express 和 Socket.IO --- const app = express(); const httpServer = http.createServer(app); const io = new Server(httpServer, { cors: { origin: "*" } }); // --- 启动服务器 --- httpServer.listen(config.listenPort, () => { console.log(`服务器已启动,正在监听端口: ${config.listenPort}`); }); // --- Mediasoup Worker 和 Router 的启动函数 --- async function startMediasoup() { worker = await mediasoup.createWorker({ logLevel: config.mediasoup.worker.logLevel, logTags: config.mediasoup.worker.logTags, rtcMinPort: config.mediasoup.worker.rtcMinPort, rtcMaxPort: config.mediasoup.worker.rtcMaxPort, }); worker.on('died', () => { console.error('mediasoup worker died, exiting in 2 seconds...'); setTimeout(() => process.exit(1), 2000); }); const mediaCodecs = config.mediasoup.router.mediaCodecs; router = await worker.createRouter({ mediaCodecs }); console.log('Mediasoup Worker 和 Router 已成功创建!'); } // --- 启动 Mediasoup --- startMediasoup(); // --- Socket.IO 信令处理逻辑 (后续填充) --- io.on('connection', (socket) => { console.log(`新客户端连接: ${socket.id}`); socket.on('disconnect', () => { console.log(`客户端断开连接: ${socket.id}`); // TODO: 清理该客户端相关的资源 }); // 在这里处理所有来自客户端的信令请求 // 例如: joinRoom, getRouterRtpCapabilities, createTransport, connectTransport, produce, subscribe 等 }); -
创建配置文件 config.js:
codeJavaScript
将配置与代码分离是个好习惯。在 signaling-server 目录下创建 config.js:// config.js module.exports = { listenPort: 3000, mediasoup: { worker: { rtcMinPort: 10000, rtcMaxPort: 10100, logLevel: 'warn', logTags: [ 'info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp', ], }, router: { mediaCodecs: [ { kind: 'video', mimeType: 'video/VP8', clockRate: 90000, parameters: { 'x-google-start-bitrate': 1000, }, }, // 如果需要音频,可以添加音频编解码器 // { // kind: 'audio', // mimeType: 'audio/opus', // clockRate: 48000, // channels: 2 // } ], }, // WebRtcTransport options webRtcTransport: { listenIps: [ { ip: '0.0.0.0', // 监听所有IP地址 announcedIp: null, // Mediasoup会自动发现公网IP,局域网内设为null即可 }, ], enableUdp: true, enableTcp: true, preferUdp: true, }, }, };注意: rtcMinPort 和 rtcMaxPort 定义了Mediasoup用于WebRTC传输的UDP/TCP端口范围。请确保你的防火墙允许这个范围的端口通信。
-
启动服务器进行初步测试:
codeBash
现在,运行命令:node app.js如果你的环境配置正确,你应该能看到:
codeCode服务器已启动,正在监听端口: 3000 Mediasoup Worker 和 Router 已成功创建!这表示Mediasoup的核心已经成功跑起来了!现在,我们需要填充信令逻辑。
第二步:实现服务器端的信令逻辑
目标: 编写处理客户端加入房间、创建传输通道、生产和消费流的逻辑。
这个app.js文件包含了完整的信令处理逻辑和Mediasoup媒体控制逻辑。请将这个文件的全部内容,替换掉你当前 signaling-server/app.js 文件中的所有内容。
完整的 app.js (信令服务器 + Mediasoup控制器)
codeJavaScript
// app.js
const express = require('express');
const http = require('http');
const { Server } = require("socket.io");
const mediasoup = require('mediasoup');
const config = require('./config'); // 确保你的config.js文件在同级目录
const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
cors: { origin: "*" }
});
let worker;
let router;
let producerTransport;
let producer;
// 简单的房间模型,这里我们只创建一个全局的房间
const peers = {}; // key: socket.id, value: { transports, producers, consumers }
/**
* 启动 Mediasoup Worker
*/
async function startMediasoupWorker() {
worker = await mediasoup.createWorker({
logLevel: config.mediasoup.worker.logLevel,
logTags: config.mediasoup.worker.logTags,
rtcMinPort: config.mediasoup.worker.rtcMinPort,
rtcMaxPort: config.mediasoup.worker.rtcMaxPort,
});
worker.on('died', () => {
console.error('mediasoup worker died, exiting in 2 seconds...');
setTimeout(() => process.exit(1), 2000);
});
const mediaCodecs = config.mediasoup.router.mediaCodecs;
router = await worker.createRouter({ mediaCodecs });
console.log('Mediasoup Worker 和 Router 已成功创建!');
}
// 启动 Mediasoup
startMediasoupWorker();
io.on('connection', (socket) => {
console.log(`新客户端连接: ${socket.id}`);
peers[socket.id] = {
transports: new Map(),
producers: new Map(),
consumers: new Map()
};
socket.on('disconnect', () => {
console.log(`客户端断开连接: ${socket.id}`);
// 清理该客户端的所有资源
if (peers[socket.id]) {
peers[socket.id].transports.forEach(transport => transport.close());
delete peers[socket.id];
}
});
// 客户端请求Router的RTP能力
socket.on('getRouterRtpCapabilities', (data, callback) => {
callback(router.rtpCapabilities);
});
// 客户端请求创建Transport
socket.on('createWebRtcTransport', async (data, callback) => {
try {
const { producing, consuming } = data;
const webRtcTransport_options = {
listenIps: config.mediasoup.webRtcTransport.listenIps,
enableUdp: true,
enableTcp: true,
preferUdp: true,
};
let transport = await router.createWebRtcTransport(webRtcTransport_options);
console.log(`Transport 创建成功: ${transport.id}`);
peers[socket.id].transports.set(transport.id, transport);
callback({
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters,
});
} catch (error) {
console.error('创建Transport失败:', error);
callback({ error: error.message });
}
});
// 客户端连接Transport
socket.on('connectWebRtcTransport', async (data, callback) => {
const { transportId, dtlsParameters } = data;
const transport = peers[socket.id].transports.get(transportId);
if (!transport) {
console.error(`未找到Transport: ${transportId}`);
return callback({ error: 'Transport not found' });
}
await transport.connect({ dtlsParameters });
callback({});
});
// 客户端开始生产(推流)
socket.on('produce', async (data, callback) => {
const { kind, rtpParameters, transportId } = data;
const transport = peers[socket.id].transports.get(transportId);
if (!transport) {
console.error(`未找到Transport: ${transportId}`);
return callback({ error: 'Transport not found' });
}
// 核心:调用 transport.produce()
producer = await transport.produce({ kind, rtpParameters });
peers[socket.id].producers.set(producer.id, producer);
console.log(`Producer 已创建: ${producer.id}`);
// 通知所有其他客户端,有新的producer产生了
socket.broadcast.emit('newProducer', { producerId: producer.id });
callback({ id: producer.id });
});
// 客户端请求消费(拉流)
socket.on('consume', async (data, callback) => {
const { rtpCapabilities, producerId, transportId } = data;
if (!producer || producer.id !== producerId) {
console.error(`未找到要消费的Producer: ${producerId}`);
return callback({ error: 'Producer not found' });
}
// 检查接收端是否能消费这个流
if (!router.canConsume({ producerId, rtpCapabilities })) {
console.error('客户端无法消费此流');
return callback({ error: 'Cannot consume' });
}
const transport = peers[socket.id].transports.get(transportId);
if (!transport) {
console.error(`未找到Transport: ${transportId}`);
return callback({ error: 'Transport not found' });
}
try {
let consumer = await transport.consume({
producerId,
rtpCapabilities,
paused: true, // 创建时先暂停
});
peers[socket.id].consumers.set(consumer.id, consumer);
consumer.on('transportclose', () => {
console.log(`Consumer的Transport关闭: ${consumer.id}`);
});
consumer.on('producerclose', () => {
console.log(`Consumer的Producer关闭: ${consumer.id}`);
});
const params = {
id: consumer.id,
producerId,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
};
callback(params);
} catch (error) {
console.error('创建Consumer失败:', error);
callback({ error: error.message });
}
});
// 客户端准备好后,请求恢复播放
socket.on('resume', async (data, callback) => {
const { consumerId } = data;
const consumer = peers[socket.id].consumers.get(consumerId);
if (!consumer) {
console.error(`未找到Consumer: ${consumerId}`);
return callback({ error: 'Consumer not found' });
}
await consumer.resume();
callback({});
});
});
// --- 最后,启动服务器 ---
httpServer.listen(config.listenPort, () => {
console.log(`服务器已启动,正在监听端口: ${config.listenPort}`);
});
如何使用这份代码
-
替换文件:确保将上述完整代码复制并完全覆盖你项目中的 signaling-server/app.js 文件。
-
检查 config.js: 确保你的 config.js 文件存在于 app.js 的同级目录下,并且内容正确。
-
检查 package.json: 确保你的 dependencies 中已经包含了 express, socket.io, mediasoup。
-
启动服务器: 在signaling-server目录下运行 node app.js。
这份代码实现了一个简化的单房间模型:
-
第一个连接上来并推流的客户端,会被认定为唯一的Producer (发送端)。
-
之后所有连接上来的客户端,都会在服务器收到newProducer事件后,自动尝试去消费 (subscribe) 这个已经存在的流。
这非常符合我们“一个发送端,多个接收端”的监控场景。
接下来,你需要改造你的客户端代码来与这个新的信令服务器进行交互。
第三步:改造WEB客户端 - 准备工作
安装并准备 mediasoup-client 库
Mediasoup在浏览器端需要一个专门的库来配合工作
方案:使用打包工具 (推荐,一劳永逸)
这是最专业、最推荐的做法。我们将使用一个极其简单的打包工具 Vite,它以“快”和“零配置”著称。
1. 初始化Vite项目(中途会遇到vite版本问题导致下载和运行报错,降低vite的版本即可一般7.0.0及以下都可以)
-
在你的项目根目录 D:\webrtc-project 下打开命令行。
-
运行以下命令,使用Vite来创建一个新的前端项目(我们会把现有的web-client内容移进去):
codeBashnpm create vite@latest web-frontend -- --template vanilla-
web-frontend 是我们将要创建的新文件夹名。
-
--template vanilla 表示创建一个最基础的、纯JavaScript的项目。
-
-
根据提示,进入新创建的目录并安装依赖:
codeBashcd web-frontend npm install
2. 安装 mediasoup-client 和 socket.io-client
-
在新创建的 web-frontend 目录里,为这个前端项目单独安装它所需要的库:
codeBashnpm install mediasoup-client@3 socket.io-client
3. 迁移你的文件
-
将你之前 web-client 文件夹下的 sender.html, receiver.html, sender.js, receiver.js 移动或复制到新的 web-frontend 文件夹的根目录下。
-
Vite的入口HTML文件是 index.html,你可以把receiver.html的内容复制到index.html里,或者保留多个HTML文件(Vite也支持多页面)。我们先以receiver.html和sender.html为例。
4. 修改HTML以适应模块化
-
修改 sender.html 和 receiver.html:
-
删除 <script src="socket.io.min.js">。注意: 之前引入socket.io.min.js的<script>标签必须删除。
-
修改你自己的JS文件引入方式,在<script>标签中加入 type="module"。
codeHtml<!-- sender.html --> <script type="module" src="/sender.js"></script> <!-- receiver.html --> <script type="module" src="/receiver.js"></script>
-
完整的receiver.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebRTC 共享接收端</title>
<style>
body { font-family: sans-serif; background-color: #f0f0f0; padding: 20px; }
h1 { text-align: center; }
#video-container { max-width: 800px; margin: 20px auto; background-color: black; }
video { width: 100%; display: block; }
#status { text-align: center; margin-top: 10px; font-size: 1.2em; }
</style>
</head>
<body>
<h1>WebRTC 网页共享 - 接收端</h1>
<div id="video-container">
<video id="remoteVideo" autoplay playsinline muted></video> <!-- 加上muted确保自动播放 -->
</div>
<p id="status">正在等待共享方发起连接...</p>
<script type="module" src="receiver.js"></script>
</body>
</html>
完整的sender.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebRTC 共享发送端</title>
<style>
body { font-family: sans-serif; text-align: center; padding-top: 50px; }
button { font-size: 1.5em; padding: 10px 20px; cursor: pointer; }
#localVideo { display: none; } /* 本地预览视频默认不显示 */
</style>
</head>
<body>
<h1>WebRTC 网页共享 - 发送端</h1>
<p>点击下方按钮,选择要共享的浏览器标签页。</p>
<button id="startButton">开始共享</button>
<p id="status">准备就绪</p>
<!-- 用于本地预览(可选) -->
<!-- <video id="localVideo" autoplay muted playsinline></video> -->
<script type="module" src="sender.js"></script>
</body>
</html>
5.sender.js 完整模块化代码
请用以下完整代码替换你的 web-frontend/sender.js 文件。
主要的改动在文件的最顶部和创建Device实例的地方。
codeJavaScript
// sender.js (Vite 模块化版本)
import { Device } from "mediasoup-client";
import { io } from "socket.io-client";
// --- 1. 配置 ---
const SERVER_IP = '192.168.0.102'; // !!! 务必修改为你的服务器IP地址
const SERVER_PORT = 3000;
const startButton = document.getElementById('startButton');
const statusDiv = document.getElementById('status');
let device;
let sendTransport;
let producer;
let localStream;
const socket = io(`http://${SERVER_IP}:${SERVER_PORT}`);
// --- 2. 信令服务器连接 ---
socket.on('connect', () => {
console.log('成功连接到信令服务器!');
statusDiv.textContent = '已连接信令服务器';
startButton.disabled = false;
});
socket.on('disconnect', () => {
console.log('与信令服务器断开连接');
statusDiv.textContent = '信令连接已断开';
startButton.disabled = true;
});
// --- 3. 核心功能函数 ---
/**
* 封装一个Promise风格的socket.emit请求
*/
function sendRequest(type, payload) {
return new Promise((resolve, reject) => {
socket.emit(type, payload, (data) => {
if (data && data.error) {
console.error(`请求'${type}'失败:`, data.error);
reject(data.error);
} else {
resolve(data);
}
});
});
}
/**
* 开始屏幕共享的主流程
*/
async function startSharing() {
try {
startButton.disabled = true;
statusDiv.textContent = '正在请求屏幕捕获...';
// 1. 获取本地媒体流
localStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const videoTrack = localStream.getVideoTracks()[0];
statusDiv.textContent = '屏幕捕获成功,正在初始化Mediasoup...';
// 2. 获取Router RTP Capabilities
const routerRtpCapabilities = await sendRequest('getRouterRtpCapabilities', {});
console.log('获取Router RTP Capabilities成功:', routerRtpCapabilities);
// 3. 创建Mediasoup Device
device = new Device(); // <-- 修改点:不再需要 mediasoupClient.Device
await device.load({ routerRtpCapabilities });
console.log('Mediasoup Device 加载成功');
// 4. 创建发送Transport
const transportInfo = await sendRequest('createWebRtcTransport', {});
console.log('创建Send Transport成功:', transportInfo);
sendTransport = device.createSendTransport(transportInfo);
// 5. 监听Transport的 'connect' 和 'produce' 事件
sendTransport.on('connect', async ({ dtlsParameters }, callback, errback) => {
console.log('Transport "connect" event');
try {
// 将DTLS参数发送给服务器
await sendRequest('connectWebRtcTransport', { transportId: sendTransport.id, dtlsParameters });
callback(); // 告诉Transport连接成功
} catch (error) {
errback(error);
}
});
sendTransport.on('produce', async ({ kind, rtpParameters }, callback, errback) => {
console.log('Transport "produce" event');
try {
// 将媒体信息发送给服务器,获取producer id
const { id } = await sendRequest('produce', {
transportId: sendTransport.id,
kind,
rtpParameters,
});
callback({ id }); // 告诉Transport,服务器已成功创建Producer
} catch (error) {
errback(error);
}
});
// 6. 调用transport.produce()开始推流
producer = await sendTransport.produce({ track: videoTrack });
statusDiv.textContent = '共享已开始!';
console.log('成功创建Producer:', producer);
} catch (error) {
console.error('开始共享失败:', error);
statusDiv.textContent = `错误: ${error.message}`;
startButton.disabled = false;
}
}
// --- 4. 绑定事件 ---
startButton.disabled = true; // 初始时禁用,等待socket连接成功
startButton.onclick = startSharing;
6.receiver.js 完整模块化代码
请用以下完整代码替换你的 web-frontend/receiver.js 文件。
codeJavaScript
// receiver.js (Vite 模块化版本)
import { Device } from "mediasoup-client";
import { io } from "socket.io-client";
// --- 1. 配置 ---
const SERVER_IP = '192.168.0.102'; // !!! 务必修改为你的服务器IP地址
const SERVER_PORT = 3000;
const remoteVideo = document.getElementById('remoteVideo');
const statusDiv = document.getElementById('status');
let device;
let recvTransport;
const socket = io(`http://${SERVER_IP}:${SERVER_PORT}`);
// --- 2. 信令服务器连接 ---
socket.on('connect', async () => {
console.log('成功连接到信令服务器!');
statusDiv.textContent = '已连接信令服务器,正在初始化...';
try {
await initialize();
} catch (error) {
console.error("初始化失败:", error);
statusDiv.textContent = `错误: ${error.message}`;
}
});
socket.on('disconnect', () => {
console.log('与信令服务器断开连接');
statusDiv.textContent = '信令连接已断开';
});
// 服务器通知有新的生产者
socket.on('newProducer', ({ producerId }) => {
console.log(`收到新Producer通知: ${producerId}`);
// 开始消费这个新的Producer
consume(producerId);
});
// --- 3. 核心功能函数 ---
/**
* 封装一个Promise风格的socket.emit请求
*/
function sendRequest(type, payload) {
return new Promise((resolve, reject) => {
socket.emit(type, payload, (data) => {
if (data && data.error) {
console.error(`请求'${type}'失败:`, data.error);
reject(data.error);
} else {
resolve(data);
}
});
});
}
/**
* 初始化接收端
*/
async function initialize() {
statusDiv.textContent = '正在获取Router能力...';
// 1. 获取Router RTP Capabilities
const routerRtpCapabilities = await sendRequest('getRouterRtpCapabilities', {});
console.log('获取Router RTP Capabilities成功:', routerRtpCapabilities);
// 2. 创建Mediasoup Device
device = new Device(); // <-- 修改点:不再需要 mediasoupClient.Device
await device.load({ routerRtpCapabilities });
console.log('Mediasoup Device 加载成功');
statusDiv.textContent = '正在创建接收通道...';
// 3. 创建接收Transport
const transportInfo = await sendRequest('createWebRtcTransport', {});
console.log('创建Recv Transport成功:', transportInfo);
recvTransport = device.createRecvTransport(transportInfo);
// 4. 监听Transport的 'connect' 事件
recvTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
console.log('Transport "connect" event');
sendRequest('connectWebRtcTransport', {
transportId: recvTransport.id,
dtlsParameters,
})
.then(callback)
.catch(errback);
});
statusDiv.textContent = '准备就绪,等待发送端开始共享...';
}
/**
* 消费(订阅)一个指定的Producer
*/
async function consume(producerId) {
if (!device || !recvTransport) {
console.error('Device或RecvTransport尚未初始化');
return;
}
statusDiv.textContent = `正在订阅来自 ${producerId} 的流...`;
const { rtpCapabilities } = device;
// 请求服务器为我们创建Consumer
const consumerParams = await sendRequest('consume', { rtpCapabilities, producerId, transportId: recvTransport.id });
if (!consumerParams) {
console.error('创建Consumer失败,服务器返回空参数');
return;
}
// 在本地创建Consumer实例
const consumer = await recvTransport.consume(consumerParams);
console.log('成功创建Consumer:', consumer);
const { track } = consumer;
remoteVideo.srcObject = new MediaStream([track]);
// 重要的是,要告诉服务器可以开始发送数据了
await sendRequest('resume', { consumerId: consumer.id });
statusDiv.textContent = '正在播放!';
7.启动和测试
-
确保你的signaling-server中的app.js正在运行 (node app.js)。
-
进入web-frontend目录。
-
运行npm run dev来启动Vite开发服务器。
-
在浏览器中打开命令行提示的地址(如 http://localhost:5173/),分别访问sender.html和receiver.html页面进行测试。
8.启动后可能会遇到的一些问题
如何让局域网可访问 (现在不需要,但以后会用到)
-
在Vite的命令行窗口按 Ctrl + C 停止当前服务器。
-
修改web-frontend/package.json文件,在"dev"脚本里加上--host。
codeJson// package.json "scripts": { "dev": "vite --host", "build": "vite build", "preview": "vite preview" }, -
重新运行 npm run dev。
-
这次,Vite的输出会变成类似这样:
codeCodeVITE v5.4.19 ready in 189 ms ➜ Local: http://localhost:5173/ ➜ Network: http://192.168.0.102:5173/ <-- 多了这一行! ➜ press h + enter to show help
效果图,发现黑屏:

解决方案:
检查 consumer.track 状态并手动播放 (最可能的解决方案)
即使有autoplay,在复杂的异步流程后,有时还是需要手动触发一下play()。我们来添加一些日志,并尝试手动播放。
修改 receiver.js 的 consume 函数:
codeJavaScript
async function consume(producerId) {
// ... (前面的代码不变) ...
const consumer = await recvTransport.consume(consumerParams);
console.log('成功创建Consumer:', consumer);
// --- 新增调试代码 ---
const { track } = consumer;
console.log('获取到的Track:', track);
console.log(`Track状态 (readyState): ${track.readyState}`);
console.log(`Track是否静音 (muted): ${track.muted}`);
console.log(`Track是否启用 (enabled): ${track.enabled}`);
// --- 修改渲染逻辑 ---
const stream = new MediaStream([track]);
remoteVideo.srcObject = stream;
// 告诉服务器可以开始发送数据了
await sendRequest('resume', { consumerId: consumer.id });
statusDiv.textContent = '正在尝试播放...';
// **关键改动:添加一个微小的延时后,手动调用play()**
// 这是为了确保 srcObject 已经完全被<video>元素加载
setTimeout(() => {
remoteVideo.play().then(() => {
console.log("视频手动播放成功!");
statusDiv.textContent = '正在播放!';
}).catch(error => {
console.error("视频手动播放失败:", error);
statusDiv.textContent = `播放失败: ${error.message},请尝试点击视频。`;
// 在播放失败时,可以添加一个点击事件来再次尝试
remoteVideo.onclick = () => {
remoteVideo.play();
};
});
}, 100); // 100毫秒的延时通常足够
}
检查浏览器的 WebRTC 内部状态
如果方案一仍然无效,我们可以使用浏览器内置的强大工具来查看底层发生了什么。
-
在接收端的Chrome浏览器中,打开一个新的标签页。
-
在地址栏输入 chrome://webrtc-internals 并回车。
-
你会看到一个包含了当前所有WebRTC连接的页面。找到与你的接收端页面对应的条目。
-
检查以下几个关键指标:
-
iceConnectionState: 它应该是connected或completed。如果是其他状态,说明P2P连接有问题。
-
bytesReceived: 找到ssrc_..._recv或类似的入站流统计。看看bytesReceived这个值是否在持续增长。如果它在增长,说明视频数据确实在源源不断地到达浏览器,问题100%出在渲染环节。如果它一直是0或不变,说明数据流没有过来,问题可能在SFU或网络层面。
-
framesDecoded: 如果bytesReceived在增长,再看看framesDecoded(解码帧数)是否也在增长。如果也在增长,说明浏览器不仅收到了数据,还成功解码了,那黑屏就非常奇怪了,可能与显卡驱动或浏览器bug有关。
-
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
我们需要修改服务器端的配置,明确告诉Mediasoup在局域网环境下应该使用哪个IP地址。
修改 signaling-server/config.js 文件:
-
找到webRtcTransport的配置部分。
-
将announcedIp的值从null修改为你的服务器的局域网IP地址(就是你在客户端JS里填写的那个SERVER_IP)。
codeJavaScript
// config.js
module.exports = {
// ...
mediasoup: {
// ...
webRtcTransport: {
listenIps: [
{
ip: '0.0.0.0', // 继续监听所有网络接口
// --- 关键修改在这里 ---
// 明确宣告服务器的局域网IP地址
announcedIp: '192.168.0.102', // <-- 请确保这个IP是你运行app.js的电脑的IP
},
],
enableUdp: true,
enableTcp: true,
preferUdp: true,
},
},
};
这个修改的含义是:
-
ip: '0.0.0.0': Mediasoup服务本身依然在服务器的所有网络接口上监听连接。
-
announcedIp: '192.168.0.102': 当客户端来请求连接时,Mediasoup会在生成的ICE候选者中明确告诉客户端:“请通过192.168.0.102这个地址来连接我”,而不是让它自己去猜。
第四步:测试程序
如何测试“一对多”监控 (一个发送端,多个接收端)
这是最符合你监控需求的场景。操作步骤如下:
-
确保服务器正在运行:
-
你的signaling-server中的node app.js正在运行。
-
-
启动Vite开发服务器:
-
在web-frontend目录下,确保npm run dev正在运行。
-
-
打开多个接收端 (监控屏幕):
-
在你的浏览器中,打开第一个receiver.html页面。地址是: http://localhost:5173/receiver.html。
-
再打开一个新标签页,输入相同的地址,打开第二个receiver.html页面。
-
再打开一个新标签页,打开第三个receiver.html页面。
-
……你可以根据需要打开任意多个。现在,你就模拟了多个监控屏幕,它们都在静静地等待信号。
-
-
打开发送端 (被监控目标):
-
在浏览器中,打开一个sender.html页面。地址是:http://localhost:5173/sender.html。
-
-
开始共享:
-
在sender.html页面,点击“开始共享”按钮,并选择一个你想要共享的标签页或窗口。
-
-
见证奇迹:
-
切换回你之前打开的所有receiver.html标签页。
-
你会发现,每一个接收端页面上,都同时开始播放来自发送端的实时画面了!
-
这个测试完美地证明了SFU的威力:你的发送端只推了一路流,但SFU服务器智能地将它复制并分发给了所有订阅者。
如何测试“多对多” (多个发送端,多个接收端)
我们当前的代码实现是一个简化的“单生产者”模型,所以无法直接测试多对多。但是,只需要对服务器端的app.js做一个非常小的改动,就可以让它支持多个生产者。
第一步:修改 app.js 以支持多生产者
我们需要修改服务器逻辑,让它能够管理一个生产者列表,而不是只管理一个全局的producer。
-
修改全局变量:
codeJavaScript
将let producer;改为一个对象来存储所有的生产者。// app.js // let producerTransport; // 可以移除或保留,取决于你的清理逻辑 // let producer; // <-- 删除这一行 const producers = new Map(); // <-- 新增:使用Map来存储所有producer,key是producer.id -
修改 produce 事件处理器:
codeJavaScript
当新的生产者创建时,将它存入producers这个Map中。// 'produce' 事件 socket.on('produce', async (data, callback) => { // ... const transport = peers[socket.id].transports.get(transportId); // ... const producer = await transport.produce({ kind, rtpParameters }); // <-- producer变为局部变量 peers[socket.id].producers.set(producer.id, producer); producers.set(producer.id, producer); // <-- 新增:将producer存入全局列表 console.log(`Producer 已创建: ${producer.id}`); // 当producer关闭时,从列表中移除 producer.on('transportclose', () => { console.log(`Producer's transport closed: ${producer.id}`); producers.delete(producer.id); }); // 通知所有其他客户端,有新的producer产生了 socket.broadcast.emit('newProducer', { producerId: producer.id }); callback({ id: producer.id }); }); -
修改 consume 事件处理器:
codeJavaScript
让它可以根据客户端请求的producerId,从producers Map中找到对应的生产者来消费。// 'consume' 事件 socket.on('consume', async (data, callback) => { const { rtpCapabilities, producerId, transportId } = data; const producer = producers.get(producerId); // <-- 修改:从Map中获取producer if (!producer) { console.error(`未找到要消费的Producer: ${producerId}`); return callback({ error: 'Producer not found' }); } // ... 后续代码不变 ... }); -
修改 connect 时的逻辑 (重要!)
codeJavaScript
当一个新的接收端连接上来时,它需要知道当前已经存在的所有生产者,而不仅仅是未来新产生的。// io.on('connection', ...) io.on('connection', (socket) => { // ... // 当新客户端连接时,立即告诉它所有已存在的producer if (producers.size > 0) { const producerIds = [...producers.keys()]; socket.emit('existingProducers', producerIds); } // ... 其他事件处理器 ... });
第二步:修改 receiver.js 以处理多个生产者
接收端需要处理existingProducers事件,并为每个已存在的生产者都创建一个消费逻辑。
codeJavaScript
// receiver.js
// ...
// 接收端连接成功后
socket.on('connect', async () => {
// ... initialize() 照常调用 ...
});
// 新增:处理已存在的生产者列表
socket.on('existingProducers', (producerIds) => {
console.log(`收到已存在的Producers列表:`, producerIds);
producerIds.forEach(producerId => {
consume(producerId);
});
});
// 服务器通知有新的生产者 (这个逻辑保持不变)
socket.on('newProducer', ({ producerId }) => {
console.log(`收到新Producer通知: ${producerId}`);
consume(producerId);
});
async function consume(producerId) {
// ... 消费逻辑 ...
const { track } = consumer;
// remoteVideo.srcObject = new MediaStream([track]); // <-- 这里需要修改!
// --- 新的渲染逻辑,支持多视频 ---
// 创建一个新的video元素并添加到页面上
const newVideo = document.createElement('video');
newVideo.id = `video-${producerId}`;
newVideo.srcObject = new MediaStream([track]);
newVideo.autoplay = true;
newVideo.playsInline = true;
newVideo.muted = true; // 多个视频通常需要静音
document.getElementById('video-container').appendChild(newVideo);
// ...
}
第三步:测试“多对多”
-
重启node app.js让服务器修改生效。
-
打开2个sender.html页面。
-
打开2个receiver.html页面。
-
在第一个sender页面开始共享。 -> 两个receiver页面都应该显示第一个sender的画面。
-
在第二个sender页面开始共享。 -> 两个receiver页面的video-container里,都应该会新增一个video元素,开始播放第二个sender的画面。
现在,你的接收端就可以同时看到来自多个发送端的视频了!这就是一个基本的多对多视频会议/监控系统的雏形。
第五步:http协议下访问共享功能的问题
默认情况下,Vite开发服务器只允许来自localhost(即本机)的访问。
这是一种安全措施,防止你局域网内的其他人无意中访问到你正在开发的、可能还不稳定的网站。
问题分析
-
http://localhost:5173/receiver.html 能访问:
-
localhost 是一个特殊的主机名,它总是指向你自己的电脑 (127.0.0.1)。
-
当你从本机的浏览器访问localhost时,Vite服务器看到了一个来自它所允许的地址的请求,所以正常响应了。
-
-
http://192.168.0.102:5173/receiver.html 无法访问:
-
192.168.0.102 是你在局域网中的IP地址。
-
当你使用这个IP地址访问时,即使是从本机的浏览器,Vite服务器也会认为这是一个来自“外部网络”(相对于localhost而言)的请求。
-
由于Vite默认没有配置为“对外开放”,它会拒绝这个连接,导致你的浏览器显示“无法访问此网站”或类似的错误。
-
解决方案:让Vite服务器“对外开放”
你需要明确地告诉Vite:“请监听所有网络接口的请求,不要只局限于localhost”。这可以通过添加一个--host参数来实现。
操作步骤
-
停止当前的Vite服务器:
-
在你运行npm run dev的那个命令行窗口,按 Ctrl + C 来停止它。
-
-
修改package.json文件:
-
打开你前端项目 web-frontend 目录下的 package.json 文件。
-
找到 "scripts" 部分。
-
在你现有的 "dev" 命令后面,添加 --host 参数。
修改前可能是这样:
codeJson"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" },修改后应该是这样:
codeJson"scripts": { "dev": "vite --host", "build": "vite build", "preview": "vite preview" },--host 参数也可以写成 --host 0.0.0.0,它们的效果是一样的,都是让服务器监听所有可用的网络接口。
-
-
重新启动Vite服务器:
-
在web-frontend目录下,再次运行命令:
codeBashnpm run dev
-
-
查看新的输出信息:
-
这次,当你启动Vite后,命令行输出会变得不一样,它会明确地告诉你局域网的访问地址:
codeCodeVITE v5.x.x ready in xxx ms ➜ Local: http://localhost:5173/ ➜ Network: http://192.168.0.102:5173/ <-- 关键!现在多了这一行 ➜ ...
-
你们还会遇到一个问题:
成功连接到信令服务器!
sender.js:113 开始共享失败: TypeError: Cannot read properties of undefined (reading 'getDisplayMedia')
at HTMLButtonElement.startSharing (sender.js:60:52)
startSharing @ sender.js:113
解决方案:
-
安装一个插件:
codeBash
Vite的核心功能不包含这个,但可以通过一个官方插件来实现。在 web-frontend 目录下,运行:npm install @vitejs/plugin-basic-ssl -D-D 表示这是一个开发依赖。
-
配置Vite:
-
在web-frontend目录下,找到(或创建)一个名为 vite.config.js 的文件。
-
将以下内容粘贴进去:
// vite.config.js import { defineConfig } from 'vite'; import basicSsl from '@vitejs/plugin-basic-ssl'; export default defineConfig({ plugins: [ basicSsl() // 启用basic-ssl插件 ], server: { https: true, // 关键:开启HTTPS host: true, // 关键:继续允许局域网访问 } }); -
接着将你的IP地址标记为安全来源
这个设置项藏在浏览器的实验性功能标志(Flags)页面里。
操作步骤 (以Chrome为例,Edge类似)
-
打开浏览器的Flags页面:
-
在Chrome或Edge的地址栏中,输入以下地址并回车:
chrome://flags
-
-
搜索相关标志:
-
在Flags页面的顶部搜索框中,输入 “insecure”。
-
-
找到并启用目标标志:
-
你应该会看到一个名为 Insecure origins treated as secure 的选项。
-
它的描述是:“将不安全的来源(origins)视为安全来源。可以使用逗号分隔的列表来指定多个来源。”
-
-
输入你的服务器地址:
-
在这个选项右侧的输入框中,精确地输入你的Vite开发服务器的http地址(不需要路径,只需要 协议://主机:端口)。
-
请填入:http://192.168.0.102:5173
-
如果你有多个需要信任的地址,可以用逗号隔开。
-
-
启用并重启浏览器:
-
在输入框下方的下拉菜单中,确保状态是 Enabled (通常在你输入内容后会自动变为Enabled)。
-
页面底部会出现一个蓝色的 Relaunch (重启) 按钮。点击它,让浏览器重启以使设置生效。
-
到这里应该就全都完成了!
更多推荐


所有评论(0)