WebRTC实时音视频之混流

WebRTC实时音视频之混流本地demo

一、完整代码实现

1. HTML 文件(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
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>多流混合系统 - 音量调节+布局切换版</title>
<style>
body { font-family: Arial, sans-serif; max-width: 1400px; margin: 20px auto; padding: 0 20px; }
.container { display: flex; gap: 20px; flex-wrap: wrap; }
.main-content { flex: 1; min-width: 800px; }
.sidebar { width: 300px; background-color: #f5f5f5; padding: 20px; border-radius: 8px; }
.controls { margin-bottom: 20px; }
button { padding: 10px 16px; margin: 5px 5px 5px 0; cursor: pointer; font-size: 14px; border: none; border-radius: 4px; background-color: #4CAF50; color: white; }
button:disabled { background-color: #ccc; cursor: not-allowed; }
button.secondary { background-color: #2196F3; }
button.danger { background-color: #f44336; }
#preview { width: 100%; max-width: 1280px; border: 2px solid #333; background-color: #000; }
.status { margin: 10px 0; padding: 10px; background-color: #e3f2fd; border-radius: 4px; border-left: 4px solid #2196F3; }
.hidden { display: none; }

/* 音量控制样式 */
.volume-control { margin: 15px 0; padding: 15px; background-color: white; border-radius: 6px; }
.volume-control h4 { margin-top: 0; color: #333; }
.volume-item { margin: 12px 0; }
.volume-item label { display: block; margin-bottom: 5px; font-size: 14px; color: #666; }
.volume-item input[type="range"] { width: 100%; }
.volume-value { float: right; font-size: 12px; color: #999; }

/* 布局切换样式 */
.layout-control { margin: 15px 0; padding: 15px; background-color: white; border-radius: 6px; }
.layout-control h4 { margin-top: 0; color: #333; }
.layout-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.layout-btn { padding: 15px; background-color: #e0e0e0; color: #333; border: 2px solid transparent; }
.layout-btn.active { background-color: #bbdefb; border-color: #2196F3; }
.layout-btn small { display: block; margin-top: 5px; font-size: 11px; color: #666; }
</style>
</head>
<body>
<h1>🎬 多流混合系统(音量调节+布局切换)</h1>

<div class="container">
<!-- 主内容区 -->
<div class="main-content">
<!-- 控制按钮 -->
<div class="controls">
<button id="startBtn">🚀 启动系统</button>
<button id="addScreenBtn" disabled>➕ 添加屏幕共享</button>
<button id="removeScreenBtn" disabled>❌ 移除屏幕共享</button>
<button id="addMP4Btn" disabled>➕ 添加 MP4</button>
<button id="removeMP4Btn" disabled>❌ 移除 MP4</button>
</div>

<!-- 状态显示 -->
<div class="status">
<strong>系统状态:</strong><span id="statusText">未启动</span>
</div>

<!-- 混合后的预览画面 -->
<h3>混合预览</h3>
<video id="preview" autoplay playsinline></video>
</div>

<!-- 侧边栏(音量+布局控制) -->
<div class="sidebar">
<h3>⚙️ 控制面板</h3>

<!-- 布局切换 -->
<div class="layout-control">
<h4>📐 布局模式</h4>
<div class="layout-buttons">
<button class="layout-btn active" data-layout="default">
默认布局
<small>摄像头全屏+两侧小窗</small>
</button>
<button class="layout-btn" data-layout="split">
左右分屏
<small>屏幕左+MP4右</small>
</button>
<button class="layout-btn" data-layout="pip">
画中画
<small>屏幕全屏+摄像头小窗</small>
</button>
<button class="layout-btn" data-layout="top-bottom">
上下分屏
<small>屏幕上+MP4下</small>
</button>
</div>
</div>

<!-- 音量控制 -->
<div class="volume-control">
<h4>🔊 音量调节</h4>
<div class="volume-item">
<label>
麦克风
<span class="volume-value" id="micVolumeValue">100%</span>
</label>
<input type="range" id="micVolume" min="0" max="100" value="100" disabled>
</div>
<div class="volume-item">
<label>
屏幕共享音频
<span class="volume-value" id="screenVolumeValue">100%</span>
</label>
<input type="range" id="screenVolume" min="0" max="100" value="100" disabled>
</div>
<div class="volume-item">
<label>
MP4 音频
<span class="volume-value" id="mp4VolumeValue">100%</span>
</label>
<input type="range" id="mp4Volume" min="0" max="100" value="100" disabled>
</div>
</div>
</div>
</div>

<!-- 隐藏的视频元素(用于读取流数据) -->
<video id="cameraVideo" autoplay playsinline muted class="hidden"></video>
<video id="screenVideo" autoplay playsinline muted class="hidden"></video>
<video id="mp4Video" autoplay playsinline muted loop class="hidden"></video>

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

2. JavaScript 文件(app.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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
// ==================== 全局状态管理 ====================
const state = {
isRunning: false,
showScreen: false,
showMP4: false,
currentLayout: 'default', // 布局模式:default, split, pip, top-bottom
streams: {
camera: null,
mic: null,
screen: null,
mp4: null
},
audioContext: null,
audioDestination: null,
audioNodes: new Map(), // key -> {source, gainNode}
canvas: null,
ctx: null,
mixedStream: null
};

// ==================== DOM 元素引用 ====================
const dom = {
startBtn: document.getElementById('startBtn'),
addScreenBtn: document.getElementById('addScreenBtn'),
removeScreenBtn: document.getElementById('removeScreenBtn'),
addMP4Btn: document.getElementById('addMP4Btn'),
removeMP4Btn: document.getElementById('removeMP4Btn'),
statusText: document.getElementById('statusText'),
preview: document.getElementById('preview'),
cameraVideo: document.getElementById('cameraVideo'),
screenVideo: document.getElementById('screenVideo'),
mp4Video: document.getElementById('mp4Video'),
// 音量控制
micVolume: document.getElementById('micVolume'),
screenVolume: document.getElementById('screenVolume'),
mp4Volume: document.getElementById('mp4Volume'),
micVolumeValue: document.getElementById('micVolumeValue'),
screenVolumeValue: document.getElementById('screenVolumeValue'),
mp4VolumeValue: document.getElementById('mp4VolumeValue'),
// 布局控制
layoutButtons: document.querySelectorAll('.layout-btn')
};

// ==================== 工具函数 ====================
function updateStatus(text) {
dom.statusText.textContent = text;
console.log(`[状态] ${text}`);
}

function updateButtons() {
dom.startBtn.disabled = state.isRunning;
dom.addScreenBtn.disabled = !state.isRunning || state.showScreen;
dom.removeScreenBtn.disabled = !state.isRunning || !state.showScreen;
dom.addMP4Btn.disabled = !state.isRunning || state.showMP4;
dom.removeMP4Btn.disabled = !state.isRunning || !state.showMP4;

// 更新音量滑块状态
dom.micVolume.disabled = !state.isRunning;
dom.screenVolume.disabled = !state.isRunning || !state.showScreen;
dom.mp4Volume.disabled = !state.isRunning || !state.showMP4;
}

// ==================== 媒体流获取 ====================
async function getCameraStream() {
updateStatus('正在获取摄像头...');
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false
});
dom.cameraVideo.srcObject = stream;
return stream;
}

async function getMicStream() {
updateStatus('正在获取麦克风...');
return navigator.mediaDevices.getUserMedia({
video: false,
audio: true
});
}

async function getScreenStream() {
updateStatus('正在获取屏幕共享(请在弹窗中选择)...');
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: true
});
dom.screenVideo.srcObject = stream;

// 监听屏幕共享停止事件
stream.getVideoTracks()[0].onended = () => {
removeScreenStream();
};

return stream;
}

async function getMP4Stream() {
updateStatus('正在加载 MP4 文件...');
// 请将此处替换为你实际的 MP4 文件路径
dom.mp4Video.src = 'test.mp4';
await dom.mp4Video.play();
return dom.mp4Video.captureStream();
}

// ==================== Canvas 视频混合(含多布局) ====================
function initCanvas() {
state.canvas = document.createElement('canvas');
state.ctx = state.canvas.getContext('2d');
state.canvas.width = 1920;
state.canvas.height = 1080;
}

// 绘制视频(保持比例铺满指定区域)
function drawVideoCover(video, x, y, width, height) {
if (!video || !video.videoWidth || !video.videoHeight) return;

const videoRatio = video.videoWidth / video.videoHeight;
const canvasRatio = width / height;
let drawWidth, drawHeight, offsetX, offsetY;

if (videoRatio > canvasRatio) {
drawHeight = height;
drawWidth = drawHeight * videoRatio;
offsetX = (width - drawWidth) / 2;
offsetY = 0;
} else {
drawWidth = width;
drawHeight = drawWidth / videoRatio;
offsetX = 0;
offsetY = (height - drawHeight) / 2;
}
state.ctx.drawImage(video, x + offsetX, y + offsetY, drawWidth, drawHeight);
}

// 绘制带边框的视频
function drawVideoWithBorder(video, x, y, width, height, borderColor = '#fff', borderWidth = 2) {
drawVideoCover(video, x, y, width, height);
state.ctx.strokeStyle = borderColor;
state.ctx.lineWidth = borderWidth;
state.ctx.strokeRect(x, y, width, height);
}

// 布局1:默认布局(摄像头全屏+两侧小窗)
function drawDefaultLayout() {
// 1. 摄像头全屏背景
drawVideoCover(dom.cameraVideo, 0, 0, state.canvas.width, state.canvas.height);

// 2. 屏幕共享(左侧320px)
if (state.showScreen && state.streams.screen) {
const screenWidth = 320;
const screenHeight = (dom.screenVideo.videoHeight / dom.screenVideo.videoWidth) * screenWidth;
const screenX = 20;
const screenY = (state.canvas.height - screenHeight) / 2;
drawVideoWithBorder(dom.screenVideo, screenX, screenY, screenWidth, screenHeight);
}

// 3. MP4(右侧320px)
if (state.showMP4 && state.streams.mp4) {
const mp4Width = 320;
const mp4Height = (dom.mp4Video.videoHeight / dom.mp4Video.videoWidth) * mp4Width;
const mp4X = state.canvas.width - mp4Width - 20;
const mp4Y = (state.canvas.height - mp4Height) / 2;
drawVideoWithBorder(dom.mp4Video, mp4X, mp4Y, mp4Width, mp4Height);
}
}

// 布局2:左右分屏(屏幕左+MP4右)
function drawSplitLayout() {
const halfWidth = state.canvas.width / 2;

// 左侧:屏幕共享(或摄像头)
if (state.showScreen && state.streams.screen) {
drawVideoCover(dom.screenVideo, 0, 0, halfWidth, state.canvas.height);
} else {
drawVideoCover(dom.cameraVideo, 0, 0, halfWidth, state.canvas.height);
}

// 右侧:MP4(或摄像头)
if (state.showMP4 && state.streams.mp4) {
drawVideoCover(dom.mp4Video, halfWidth, 0, halfWidth, state.canvas.height);
} else {
drawVideoCover(dom.cameraVideo, halfWidth, 0, halfWidth, state.canvas.height);
}

// 中间分隔线
state.ctx.strokeStyle = '#fff';
state.ctx.lineWidth = 3;
state.ctx.beginPath();
state.ctx.moveTo(halfWidth, 0);
state.ctx.lineTo(halfWidth, state.canvas.height);
state.ctx.stroke();
}

// 布局3:画中画(屏幕全屏+摄像头小窗)
function drawPipLayout() {
// 1. 屏幕共享全屏(或摄像头)
if (state.showScreen && state.streams.screen) {
drawVideoCover(dom.screenVideo, 0, 0, state.canvas.width, state.canvas.height);
} else {
drawVideoCover(dom.cameraVideo, 0, 0, state.canvas.width, state.canvas.height);
}

// 2. 摄像头画中画(右下角)
const pipWidth = 320;
const pipHeight = (dom.cameraVideo.videoHeight / dom.cameraVideo.videoWidth) * pipWidth;
const pipX = state.canvas.width - pipWidth - 20;
const pipY = state.canvas.height - pipHeight - 20;
drawVideoWithBorder(dom.cameraVideo, pipX, pipY, pipWidth, pipHeight, '#ff9800', 3);

// 3. MP4 小窗(左上角,若存在)
if (state.showMP4 && state.streams.mp4) {
const mp4PipWidth = 320;
const mp4PipHeight = (dom.mp4Video.videoHeight / dom.mp4Video.videoWidth) * mp4PipWidth;
drawVideoWithBorder(dom.mp4Video, 20, 20, mp4PipWidth, mp4PipHeight, '#4CAF50', 3);
}
}

// 布局4:上下分屏(屏幕上+MP4下)
function drawTopBottomLayout() {
const halfHeight = state.canvas.height / 2;

// 上半部分:屏幕共享(或摄像头)
if (state.showScreen && state.streams.screen) {
drawVideoCover(dom.screenVideo, 0, 0, state.canvas.width, halfHeight);
} else {
drawVideoCover(dom.cameraVideo, 0, 0, state.canvas.width, halfHeight);
}

// 下半部分:MP4(或摄像头)
if (state.showMP4 && state.streams.mp4) {
drawVideoCover(dom.mp4Video, 0, halfHeight, state.canvas.width, halfHeight);
} else {
drawVideoCover(dom.cameraVideo, 0, halfHeight, state.canvas.width, halfHeight);
}

// 中间分隔线
state.ctx.strokeStyle = '#fff';
state.ctx.lineWidth = 3;
state.ctx.beginPath();
state.ctx.moveTo(0, halfHeight);
state.ctx.lineTo(state.canvas.width, halfHeight);
state.ctx.stroke();
}

// 主绘制循环
function startDrawingLoop() {
function draw() {
// 清空画布
state.ctx.fillStyle = '#000';
state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height);

// 根据当前布局绘制
switch (state.currentLayout) {
case 'split':
drawSplitLayout();
break;
case 'pip':
drawPipLayout();
break;
case 'top-bottom':
drawTopBottomLayout();
break;
default:
drawDefaultLayout();
}

requestAnimationFrame(draw);
}
draw();
}

// ==================== 音频混合(含音量调节) ====================
function initAudio() {
state.audioContext = new (window.AudioContext || window.webkitAudioContext)();
state.audioDestination = state.audioContext.createMediaStreamDestination();
}

function addAudioToMix(name, stream) {
if (!stream.getAudioTracks().length) return;

const source = state.audioContext.createMediaStreamSource(stream);
const gainNode = state.audioContext.createGain();
gainNode.gain.value = 1; // 默认音量 100%

source.connect(gainNode);
gainNode.connect(state.audioDestination);

state.audioNodes.set(name, { source, gainNode });
updateStatus(`音频 "${name}" 已添加`);
updateButtons();
}

function removeAudioFromMix(name) {
if (!state.audioNodes.has(name)) return;

const { source, gainNode } = state.audioNodes.get(name);
source.disconnect();
gainNode.disconnect();
state.audioNodes.delete(name);
updateStatus(`音频 "${name}" 已移除`);
updateButtons();
}

// 设置音量(0-100)
function setVolume(name, value) {
if (!state.audioNodes.has(name)) return;

const { gainNode } = state.audioNodes.get(name);
gainNode.gain.value = value / 100; // 转换为 0-1 范围

// 更新显示
const valueElement = document.getElementById(`${name}VolumeValue`);
if (valueElement) {
valueElement.textContent = `${value}%`;
}
}

// ==================== 动态增删流 ====================
async function addScreenStream() {
try {
state.streams.screen = await getScreenStream();
await new Promise(res => dom.screenVideo.onloadedmetadata = res);
addAudioToMix('screen', state.streams.screen);

// 恢复之前的音量设置
setVolume('screen', dom.screenVolume.value);

state.showScreen = true;
updateStatus('屏幕共享已添加');
updateButtons();
} catch (err) {
updateStatus(`添加屏幕共享失败: ${err.message}`);
console.error(err);
}
}

function removeScreenStream() {
state.showScreen = false;
removeAudioFromMix('screen');

if (state.streams.screen) {
state.streams.screen.getTracks().forEach(track => track.stop());
state.streams.screen = null;
}

dom.screenVideo.srcObject = null;
updateStatus('屏幕共享已移除');
updateButtons();
}

async function addMP4Stream() {
try {
state.streams.mp4 = await getMP4Stream();
await new Promise(res => dom.mp4Video.onloadedmetadata = res);
addAudioToMix('mp4', state.streams.mp4);

// 恢复之前的音量设置
setVolume('mp4', dom.mp4Volume.value);

state.showMP4 = true;
updateStatus('MP4 已添加');
updateButtons();
} catch (err) {
updateStatus(`添加 MP4 失败: ${err.message}`);
console.error(err);
}
}

function removeMP4Stream() {
state.showMP4 = false;
removeAudioFromMix('mp4');

dom.mp4Video.pause();
dom.mp4Video.src = '';
if (state.streams.mp4) {
state.streams.mp4.getTracks().forEach(track => track.stop());
state.streams.mp4 = null;
}

dom.mp4Video.srcObject = null;
updateStatus('MP4 已移除');
updateButtons();
}

// ==================== 布局切换 ====================
function switchLayout(layoutName) {
state.currentLayout = layoutName;

// 更新按钮状态
dom.layoutButtons.forEach(btn => {
btn.classList.toggle('active', btn.dataset.layout === layoutName);
});

updateStatus(`已切换到 "${getLayoutName(layoutName)}" 布局`);
}

function getLayoutName(layout) {
const names = {
'default': '默认布局',
'split': '左右分屏',
'pip': '画中画',
'top-bottom': '上下分屏'
};
return names[layout] || layout;
}

// ==================== 系统启动 ====================
async function startSystem() {
try {
updateStatus('正在启动系统...');
state.isRunning = true;

initCanvas();
initAudio();

state.streams.camera = await getCameraStream();
state.streams.mic = await getMicStream();

await new Promise(res => dom.cameraVideo.onloadedmetadata = res);

startDrawingLoop();
addAudioToMix('mic', state.streams.mic);

// 设置初始音量
setVolume('mic', dom.micVolume.value);

const mixedVideoStream = state.canvas.captureStream(30);
state.mixedStream = new MediaStream([
...mixedVideoStream.getVideoTracks(),
...state.audioDestination.stream.getAudioTracks()
]);

dom.preview.srcObject = state.mixedStream;

updateStatus('系统启动成功!可添加流、调节音量或切换布局');
updateButtons();
} catch (err) {
updateStatus(`系统启动失败: ${err.message}`);
console.error(err);
state.isRunning = false;
updateButtons();
}
}

// ==================== 事件绑定 ====================
dom.startBtn.addEventListener('click', startSystem);
dom.addScreenBtn.addEventListener('click', addScreenStream);
dom.removeScreenBtn.addEventListener('click', removeScreenStream);
dom.addMP4Btn.addEventListener('click', addMP4Stream);
dom.removeMP4Btn.addEventListener('click', removeMP4Stream);

// 音量控制事件
dom.micVolume.addEventListener('input', (e) => setVolume('mic', e.target.value));
dom.screenVolume.addEventListener('input', (e) => setVolume('screen', e.target.value));
dom.mp4Volume.addEventListener('input', (e) => setVolume('mp4', e.target.value));

// 布局切换事件
dom.layoutButtons.forEach(btn => {
btn.addEventListener('click', () => switchLayout(btn.dataset.layout));
});

二、新增功能详细说明

1. 音量调节功能

实现原理

  • 使用 AudioContextGainNode 控制每路音频的音量。
  • 音量范围:0-100(滑块值),内部转换为 0-1(Web Audio API 标准范围)。
  • 每路音频独立控制,互不影响。

使用方法

  • 启动系统后,右侧「音量调节」面板的滑块会自动启用。
  • 拖动滑块即可实时调节对应音频的音量(0% 为静音,100% 为最大音量)。
  • 音量值会实时显示在滑块右侧。

关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设置音量(0-100)
function setVolume(name, value) {
if (!state.audioNodes.has(name)) return;

const { gainNode } = state.audioNodes.get(name);
gainNode.gain.value = value / 100; // 转换为 0-1 范围

// 更新显示
const valueElement = document.getElementById(`${name}VolumeValue`);
if (valueElement) {
valueElement.textContent = `${value}%`;
}
}

2. 布局切换功能

实现原理

  • 定义 4 种预设布局函数,每种函数负责一种画面排列方式。
  • 通过 state.currentLayout 变量记录当前布局。
  • 在主绘制循环中根据当前布局调用对应的绘制函数。

4 种预设布局

布局名称 说明 适用场景
默认布局 摄像头全屏背景,屏幕共享在左侧 320px,MP4 在右侧 320px 视频会议、在线教育(老师画面+课件+学生)
左右分屏 屏幕共享在左半屏,MP4 在右半屏(无流时显示摄像头) 对比展示、双屏演示
画中画 屏幕共享全屏,摄像头在右下角画中画,MP4 在左上角(若存在) 游戏直播、演示讲解
上下分屏 屏幕共享在上半屏,MP4 在下半屏(无流时显示摄像头) 上下对比、分步演示

使用方法

  • 点击右侧「布局模式」面板中的按钮即可实时切换布局。
  • 当前选中的布局按钮会高亮显示。
  • 即使某路流未添加,布局也会自动适配(用摄像头填充空缺位置)。

关键代码

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
// 主绘制循环
function startDrawingLoop() {
function draw() {
// 清空画布
state.ctx.fillStyle = '#000';
state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height);

// 根据当前布局绘制
switch (state.currentLayout) {
case 'split':
drawSplitLayout();
break;
case 'pip':
drawPipLayout();
break;
case 'top-bottom':
drawTopBottomLayout();
break;
default:
drawDefaultLayout();
}

requestAnimationFrame(draw);
}
draw();
}

三、完整使用指南

1. 准备工作

  1. 替换 MP4 文件:将 app.js 第 127 行的 'test.mp4' 替换为你实际的 MP4 文件路径。
  2. 启动 HTTP 服务器
    • 使用 VS Code 的 Live Server 插件(推荐)。
    • 或使用 Node.js:npx http-server -p 8080
  3. 访问页面:在浏览器中打开 http://localhost:8080(或对应端口)。

2. 操作流程

  1. 启动系统

    • 点击「🚀 启动系统」按钮。
    • 在浏览器弹窗中允许摄像头和麦克风权限。
    • 此时预览画面会显示摄像头内容,音量面板的麦克风滑块已启用。
  2. 添加屏幕共享

    • 点击「➕ 添加屏幕共享」。
    • 在弹窗中选择要共享的屏幕、窗口或标签页。
    • (可选)勾选「分享系统音频」以捕获系统声音。
    • 此时屏幕共享会出现在画面中,音量面板的屏幕共享滑块已启用。
  3. 添加 MP4

    • 点击「➕ 添加 MP4」。
    • MP4 会自动加载并播放,出现在画面中。
    • 音量面板的 MP4 滑块已启用。
  4. 调节音量

    • 拖动右侧面板中的滑块,实时调节各路音频的音量。
    • 音量值会显示在滑块右侧(0%-100%)。
  5. 切换布局

    • 点击右侧「布局模式」面板中的任意按钮。
    • 画面会立即切换到对应的布局模式。
    • 尝试不同的布局,找到最适合你场景的排列方式。
  6. 移除流

    • 点击「❌ 移除屏幕共享」或「❌ 移除 MP4」。
    • 对应的画面和音频会立即从混合流中移除。
    • 音量滑块会自动禁用。

四、界面预览

  • 左侧主内容区:控制按钮、状态显示、混合预览画面。
  • 右侧侧边栏
    • 布局模式:4 个布局按钮,点击即可切换。
    • 音量调节:3 个独立的音量滑块,分别控制麦克风、屏幕共享和 MP4。

五、技术亮点

  1. 模块化设计:每种布局独立为一个函数,易于维护和扩展。
  2. 优雅降级:当某路流不存在时,自动用摄像头填充,避免画面空白。
  3. 状态同步:按钮状态、音量滑块状态与系统状态实时同步。
  4. 用户体验优化
    • 屏幕共享停止时自动触发移除逻辑。
    • 重新添加流时恢复之前的音量设置。
    • 布局按钮高亮显示当前选中状态。

六、延伸扩展建议

1. 自定义布局

你可以轻松添加新的布局模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 定义新布局函数
function drawMyCustomLayout() {
// 你的绘制逻辑
// 例如:四宫格、对角线布局等
}

// 2. 在 switch 语句中添加新 case
switch (state.currentLayout) {
// ... 其他 case
case 'my-custom':
drawMyCustomLayout();
break;
}

// 3. 在 HTML 中添加对应按钮
<button class="layout-btn" data-layout="my-custom">
我的布局
<small>布局说明</small>
</button>

2. 音量静音按钮

在音量滑块旁添加静音按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function toggleMute(name) {
const slider = document.getElementById(`${name}Volume`);
const currentValue = slider.value;

if (currentValue > 0) {
// 保存当前音量并静音
slider.dataset.prevValue = currentValue;
slider.value = 0;
setVolume(name, 0);
} else {
// 恢复之前的音量
slider.value = slider.dataset.prevValue || 100;
setVolume(name, slider.value);
}
}

WebRTC实时音视频之混流
https://cszy.top/20260305-WebRTC实时音视频之混流/
作者
csorz
发布于
2026年3月5日
许可协议