13、屏幕共享+摄像头:画中画模式、切换显示源、双流同时传输
屏幕共享加摄像头,这其实是WebRTC里最实用的组合拳之一。你想想看,开会时既要展示PPT,又要露个脸,这不就是刚需吗?
我个人习惯把这种场景拆成三个核心能力:画中画(PIP)、动态切换显示源、以及双流同时传输。咱们一个一个来啃。
13.1 画中画模式:小窗叠加的实现
画中画说白了就是两个视频流叠在一起。一个是大画面(屏幕共享),一个是小窗口(摄像头)。
我在项目中遇到过最坑的事——直接用两个独立的<video>标签叠层,结果小窗口的z-index死活不对。后来发现,关键不在于CSS,而在于流的管理。
核心思路:屏幕流作为主画面,摄像头流作为覆盖层。两个流分别绑定到不同的video元素,通过CSS定位实现叠加。
// 获取两个流
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
// 绑定到video元素
document.getElementById('screenVideo').srcObject = screenStream;
document.getElementById('cameraVideo').srcObject = cameraStream;
嗯,这里要注意:摄像头流一定要先获取,再获取屏幕流。为什么?因为浏览器对权限请求的顺序有讲究,先弹摄像头权限再弹屏幕共享,用户体验更顺。
小技巧:画中画的小窗位置可以做成可拖拽的。我习惯用pointer-events: none控制点击穿透,只在拖拽手柄上保留事件。
13.2 切换显示源:从摄像头切到屏幕,再切回来
动态切换显示源,说白了就是用户想换就换。比如先共享屏幕,中途想切回摄像头露个脸,再切回屏幕。
我曾经踩过一个坑:直接替换srcObject,结果旧的流没有释放,导致摄像头灯一直亮着。正确的做法是:
async function switchToScreen() {
// 先停止旧流
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
}
// 获取新流
const newStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
// 替换video源
videoElement.srcObject = newStream;
currentStream = newStream;
}
async function switchToCamera() {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop());
}
const newStream = await navigator.mediaDevices.getUserMedia({ video: true });
videoElement.srcObject = newStream;
currentStream = newStream;
}
警告:切换源时,一定要先停止旧track再获取新track。否则浏览器会报NotReadableError,因为摄像头或屏幕资源被占用了。
你可能会问:切换时画面会黑一下吗?会的。我建议在切换过程中显示一个加载动画,或者用transition做淡入淡出,用户体验会好很多。
13.3 双流同时传输:一个PeerConnection,两个Track
双流同时传输,这是画中画模式的底层支撑。说白了,就是在一个PeerConnection里同时发送屏幕流和摄像头流。
我记得第一次做这个功能时,以为要建两个PeerConnection。后来发现完全没必要——一个PeerConnection可以挂多个Track。
const pc = new RTCPeerConnection(config);
// 添加屏幕流
screenStream.getTracks().forEach(track => {
pc.addTrack(track, screenStream);
});
// 添加摄像头流
cameraStream.getTracks().forEach(track => {
pc.addTrack(track, cameraStream);
});
// 创建Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
这里有个关键点:Track的优先级。屏幕共享的帧率一般不需要太高(15fps就够了),但分辨率要高。摄像头则相反,30fps但分辨率可以低一些。
| 流类型 | 推荐分辨率 | 推荐帧率 | 码率建议 |
|---|---|---|---|
| 屏幕共享 | 1920x1080 | 15 fps | 2-4 Mbps |
| 摄像头 | 640x480 | 30 fps | 500 Kbps - 1 Mbps |
避坑指南:我曾经在双流传输时遇到接收端画面卡顿。排查后发现是发送端没有设置RTCRtpEncodingParameters的maxBitrate。两个流抢带宽,结果都卡。给每个流单独设码率上限,问题解决。
// 设置发送编码参数
const sender = pc.getSenders().find(s => s.track.kind === 'video');
const parameters = sender.getParameters();
parameters.encodings = [
{ maxBitrate: 2_000_000 } // 屏幕流限速2Mbps
];
await sender.setParameters(parameters);
13.4 整体架构图
下面这张图展示了本章的核心逻辑:从采集到传输,再到接收端渲染。
13.5 接收端:画中画渲染
接收端拿到两个流后,怎么渲染?我建议用两个<video>元素,一个铺满容器,一个绝对定位在角落。
// 接收端处理
pc.ontrack = (event) => {
if (event.track.kind === 'video') {
// 判断是屏幕流还是摄像头流
// 可以通过track.label或者自定义metadata判断
if (event.streams[0].id === 'screen-stream') {
document.getElementById('mainVideo').srcObject = event.streams[0];
} else {
document.getElementById('pipVideo').srcObject = event.streams[0];
}
}
};
个人经验:判断流类型时,不要依赖track.label,因为不同浏览器行为不一致。我习惯在信令中传递一个自定义字段,比如{ type: 'screen' }或{ type: 'camera' },这样最可靠。
嗯,画中画的位置、大小、圆角这些,交给CSS就好。我一般把小窗放在右下角,宽度占主画面的25%,再加个阴影和圆角,看着舒服。
最后说一句:双流同时传输时,带宽管理是重中之重。别忘了在RTCRtpSender上设置setParameters,给每个流分配合理的码率。否则,两个流抢带宽,画面一起糊,那就尴尬了。
公众号:蓝海资料掘金营,微信deep3321