webrtc进阶:利用getDisplayMedia实现多窗口屏幕共享实战
1. 从“共享整个屏幕”到“精准共享一个窗口”:为什么你需要getDisplayMedia进阶?
嘿,朋友们,我是老张,在音视频和实时通信这块儿折腾了十多年。今天咱们不聊基础的摄像头通话,那太“入门”了。我们来聊聊一个更酷、也更实用的场景:如何在视频会议或者远程协助时,不把整个乱七八糟的桌面都暴露给别人,而是精准地只共享你需要的那个浏览器标签页、那个PPT窗口,或者那个代码编辑器?
这就是 getDisplayMedia API 的进阶玩法。你可能在入门教程里见过它,知道它能弹出个选择框让你选“整个屏幕”或“某个窗口”。但实际用起来,你会发现一堆问题:用户不小心选了整个屏幕,隐私全暴露了;共享的窗口如果最小化了,对方看到的就是黑屏或者卡住;想动态切换共享的源,还得让用户重新点一次选择框,体验非常割裂。
我经历过太多次这样的线上事故:给客户演示产品,一共享桌面,右下角的私人聊天窗口弹了出来,场面一度十分尴尬。或者是在线教学时,老师想从PPT切换到浏览器查资料,整个流程得中断,让学生干等着。这些痛点,恰恰是 getDisplayMedia 基础功能无法满足的。
所以,今天的“进阶实战”,就是要解决这些问题。我们将深入 getDisplayMedia 的细节,探索如何实现可选择、可控制、可动态切换的多窗口屏幕共享。这不仅仅是调用一个API,而是涉及到媒体流(MediaStream)的精细管理、轨道(Track)的控制、以及如何与WebRTC的PeerConnection无缝结合。别担心,我会用最直白的话和能直接跑起来的代码,带你一步步搞定。无论你是做在线教育、远程协作、还是需要复杂演示的SaaS产品,这套方案都能让你的共享功能变得既专业又贴心。
2. 理解核心:getDisplayMedia的约束(Constraints)与能力
在撸起袖子写代码之前,咱们得先把 getDisplayMedia 这家伙的“脾气”摸清楚。它和咱们熟悉的 getUserMedia(获取摄像头)是亲兄弟,都来自 navigator.mediaDevices 这个“媒体设备总管”,但它们的“约束”对象可大不相同。
2.1 基础调用与那个弹出的选择器
最基础的调用,就像你之前学的那样:
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true // 是否同时共享系统音频或麦克风
});
执行这行代码,浏览器就会弹出那个系统级别的选择器。这个选择器长啥样、有哪些选项,完全由浏览器和操作系统决定,我们开发者没法定制它的UI。这是出于安全和隐私的硬性规定,防止恶意网站伪装成别的界面骗你共享屏幕。
但这里有个关键点:constraints 里的 video 字段,它不仅可以是一个布尔值 true,更可以是一个配置对象。这是实现进阶控制的钥匙。
2.2 关键约束参数:控制共享的“粒度”
我们把 video 设为对象,就能传递更精细的指令:
const constraints = {
video: {
displaySurface: 'window', // 核心参数:限制选择器只显示‘窗口’
// displaySurface 可选值:
// 'monitor' - 仅显示整个屏幕(显示器)
// 'window' - 仅显示应用窗口
// 'browser' - 仅显示浏览器标签页(Chrome等支持)
width: { ideal: 1920 },
height: { ideal: 1080 },
frameRate: { ideal: 30, max: 60 },
cursor: 'always' // 鼠标指针是否显示:'always', 'motion', 'never'
},
audio: {
echoCancellation: true,
noiseSuppression: true,
// 注意:系统音频捕获(captureHandle)是更高级的特性,需要额外权限和配置
}
};
try {
const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
// 此时弹出的选择器,可能就只列出所有应用窗口,而不包含“整个屏幕”的选项了。
} catch (err) {
console.error('获取显示媒体失败:', err);
}
displaySurface 这个参数太有用了! 我实测下来,当你设为 'window' 时,在Chrome和Edge上,选择器会默认过滤掉“整个屏幕”的选项,用户只能从各个应用窗口里选。这就在源头上避免了误共享整个桌面的风险,特别适合“共享PPT演示”、“共享代码编辑器”这种明确场景。
不过要注意,这个约束是“软性”的。浏览器会尽量满足,但如果用户系统不支持(比如某些旧版本),它可能还是会显示所有选项。所以我们的代码要有兼容性处理。
2.3 获取共享源的“元信息”:Capture Handle API(前瞻)
这是目前(2024年初)还处于实验性阶段但非常有前景的特性。想象一下,你共享了一个浏览器标签页,作为接收方,你能知道这个标签页的标题、URL吗?或者,你能在共享源页面(被共享的页面)里,通过程序主动控制停止共享吗?
这就是 Capture Handle API 要解决的问题。它允许被捕获的页面(共享源)和捕获它的页面(共享发起方)之间进行有限的、安全的通信。
// 在被共享的页面(例如你的演示文稿页面)中
if ('captureHandle' in document) {
document.captureHandle = {
handle: 'my-presentation-window-123',
origin: '*', // 允许哪些源可以获取这个handle,出于安全考虑应限制
exposeOrigin: false // 是否向捕获者暴露本页面源
};
}
在捕获方(发起共享的页面),你可以监听事件来获取这个信息:
const [track] = stream.getVideoTracks();
if (track.getCaptureHandle) {
const handle = track.getCaptureHandle();
console.log('捕获到源的标识:', handle.handle);
// 你可以用这个标识来做一些逻辑,比如记录当前共享的是哪个特定文档
}
这个API目前兼容性还不够广,但它是未来实现精细化、可编程化屏幕共享的重要方向。了解它,能让你在设计架构时更有前瞻性。
3. 实战:构建一个多窗口选择与切换的共享控制器
光说不练假把式。现在我们来构建一个稍微复杂点的UI:不是一上来就弹系统选择器,而是先让用户在我们自己漂亮的界面上,选择要共享的窗口类型,甚至预览(如果浏览器支持的话),然后再触发真正的 getDisplayMedia。
3.1 设计用户流程与界面
我们的目标是改善用户体验:
- 用户点击“开始共享”,先弹出我们自己的模态框。
- 模态框里有选项:“共享整个屏幕”、“共享应用窗口”、“共享浏览器标签页”。
- 用户选择一项(比如“共享应用窗口”)后,再调用带有对应
displaySurface约束的getDisplayMedia。 - 成功获取流之后,在我们自己的页面上显示一个小的“预览窗”,并显示控制按钮(如“切换共享源”、“暂停视频”、“停止共享”)。
HTML结构大概如下:
<div>
<button id="startShareBtn">开始屏幕共享</button>
<div id="customPickerModal" style="display:none;">
<h3>选择要共享的内容</h3>
<label><input type="radio" name="shareType" value="monitor" checked /> 整个屏幕</label>
<label><input type="radio" name="shareType" value="window" /> 应用窗口</label>
<label><input type="radio" name="shareType" value="browser" /> 浏览器标签页</label>
<button id="confirmShareBtn">确认并选择</button>
<button id="cancelShareBtn">取消</button>
</div>
<div id="previewContainer" style="display:none;">
<video id="previewVideo" autoplay muted playsinline width="300"></video>
<div>
<button id="switchSourceBtn">切换共享源</button>
<button id="pauseVideoBtn">暂停视频</button>
<button id="stopShareBtn">停止共享</button>
</div>
</div>
</div>
<video id="remoteVideo" autoplay playsinline width="800"></video> <!-- 用于WebRTC远端显示 -->
3.2 实现核心控制逻辑
接下来是重头戏,JavaScript部分。我们会用一个类来管理整个共享的状态。
class ScreenShareController {
constructor() {
this.localStream = null;
this.currentVideoTrack = null;
this.shareType = 'monitor'; // 默认
// 绑定DOM元素和事件
this.startShareBtn = document.getElementById('startShareBtn');
this.customPickerModal = document.getElementById('customPickerModal');
this.confirmShareBtn = document.getElementById('confirmShareBtn');
this.cancelShareBtn = document.getElementById('cancelShareBtn');
this.previewVideo = document.getElementById('previewVideo');
this.previewContainer = document.getElementById('previewContainer');
this.switchSourceBtn = document.getElementById('switchSourceBtn');
this.pauseVideoBtn = document.getElementById('pauseVideoBtn');
this.stopShareBtn = document.getElementById('stopShareBtn');
this.remoteVideo = document.getElementById('remoteVideo'); // 假设这是WebRTC远端视频
this._bindEvents();
}
_bindEvents() {
this.startShareBtn.addEventListener('click', () => this.showCustomPicker());
this.confirmShareBtn.addEventListener('click', () => this.startScreenShare());
this.cancelShareBtn.addEventListener('click', () => this.hideCustomPicker());
this.switchSourceBtn.addEventListener('click', () => this.switchSource());
this.pauseVideoBtn.addEventListener('click', () => this.togglePauseVideo());
this.stopShareBtn.addEventListener('click', () => this.stopScreenShare());
}
showCustomPicker() {
const selected = document.querySelector('input[name="shareType"]:checked');
this.shareType = selected.value;
this.customPickerModal.style.display = 'block';
}
hideCustomPicker() {
this.customPickerModal.style.display = 'none';
}
async startScreenShare() {
this.hideCustomPicker();
const constraints = this._buildConstraints();
try {
// 核心API调用
this.localStream = await navigator.mediaDevices.getDisplayMedia(constraints);
this.currentVideoTrack = this.localStream.getVideoTracks()[0];
// 本地预览
this.previewVideo.srcObject = this.localStream;
this.previewContainer.style.display = 'block';
// **极其重要:监听共享停止事件(用户点击浏览器自带的停止按钮)**
this.currentVideoTrack.onended = () => {
console.log('用户通过浏览器UI停止了共享');
this._cleanup();
};
// 这里!将流添加到WebRTC的PeerConnection中(假设你已有peerConnection实例)
// this.peerConnection.addTrack(this.currentVideoTrack, this.localStream);
// ... 后续信令交换逻辑
console.log('屏幕共享开始,轨道设置:', this.currentVideoTrack.getSettings());
} catch (error) {
console.error('启动屏幕共享失败:', error);
alert(`共享失败: ${error.message}`);
}
}
_buildConstraints() {
const baseConstraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 24 }
},
audio: false // 本例先不处理音频,可根据需要开启
};
// 根据用户选择设置displaySurface
if (this.shareType === 'window') {
baseConstraints.video.displaySurface = 'window';
} else if (this.shareType === 'browser') {
baseConstraints.video.displaySurface = 'browser';
}
// 'monitor' 类型可以不指定displaySurface,或显式设置为 'monitor'
return baseConstraints;
}
async switchSource() {
// 切换共享源的核心:先停止旧轨道,再重新获取
if (this.currentVideoTrack) {
this.currentVideoTrack.stop(); // 停止旧的轨道
// 注意:这里只是停止轨道,流本身可能还有音频轨道,需要处理
}
// 重新弹出选择器,获取新的流
try {
const newStream = await navigator.mediaDevices.getDisplayMedia(this._buildConstraints());
const newVideoTrack = newStream.getVideoTracks()[0];
// 替换本地预览
this.previewVideo.srcObject = newStream;
// **关键步骤:在WebRTC中替换发送的轨道**
// 1. 从旧的发送器中移除旧轨道
// const [sender] = this.peerConnection.getSenders().filter(s => s.track && s.track.kind === 'video');
// 2. 用新轨道替换
// if (sender) {
// sender.replaceTrack(newVideoTrack);
// }
// 更新内部状态
if (this.localStream) {
// 释放旧的流(如果已无其他活跃轨道)
this.localStream.getTracks().forEach(track => track.stop());
}
this.localStream = newStream;
this.currentVideoTrack = newVideoTrack;
this.currentVideoTrack.onended = () => {
console.log('用户停止了新的共享源');
this._cleanup();
};
console.log('共享源已切换');
} catch (error) {
console.error('切换共享源失败:', error);
// 如果用户取消选择,可以考虑恢复旧的共享,或者直接停止
this.stopScreenShare();
}
}
togglePauseVideo() {
if (this.currentVideoTrack) {
this.currentVideoTrack.enabled = !this.currentVideoTrack.enabled;
this.pauseVideoBtn.textContent = this.currentVideoTrack.enabled ? '暂停视频' : '恢复视频';
// 注意:enabled = false 是暂停帧传输,接收端会卡在最后一帧。不是停止轨道。
}
}
stopScreenShare() {
this._cleanup();
}
_cleanup() {
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}
this.localStream = null;
this.currentVideoTrack = null;
this.previewVideo.srcObject = null;
this.previewContainer.style.display = 'none';
// 同时通知WebRTC对端,停止发送/接收视频轨道
// ... WebRTC相关清理逻辑
console.log('屏幕共享已完全停止');
}
}
// 初始化控制器
const shareController = new ScreenShareController();
这段代码已经是一个功能相当完整的控制器了。它实现了类型化选择、本地预览、动态切换共享源、暂停/恢复视频以及完整的清理。其中 switchSource 方法和 WebRTC 轨道替换的逻辑是进阶的关键,我后面会详细解释。
4. 与WebRTC PeerConnection深度集成:动态替换轨道
屏幕共享的流,最终是要通过 WebRTC 发送给对方的。单纯的预览只是本地播放,真正的挑战在于:当用户点击“切换共享源”时,如何让远端的观众无缝地看到新的窗口内容,而不是断线重连?
4.1 RTCPeerConnection与轨道(Track)的关系
在WebRTC中,媒体流(MediaStream)是由一个或多个轨道(AudioTrack 或 VideoTrack)组成的。当我们通过 peerConnection.addTrack(track, stream) 添加一个轨道时,PeerConnection 内部会创建一个 RTCRtpSender 对象来负责编码和发送这个轨道的数据。
动态切换的核心,就在于替换这个 RTCRtpSender 所持有的轨道。 这正是 RTCRtpSender.replaceTrack(newTrack) 方法的用武之地。这个方法的美妙之处在于,它可以在不重新进行信令协商(也就是不打断现有的PeerConnection连接)的情况下,将发送的媒体内容从一个轨道切换到另一个轨道。
4.2 实现无缝切换的代码详解
让我们接着上面的 ScreenShareController 类,补全 switchSource 方法中与WebRTC集成的部分。假设我们已经有一个建立好的 peerConnection 实例。
class ScreenShareController {
// ... 之前的构造函数和属性 ...
// 假设我们在类中持有 peerConnection 实例
// this.peerConnection = new RTCPeerConnection(configuration);
async switchSource() {
if (!this.peerConnection) {
console.warn('PeerConnection 未初始化,无法切换');
return;
}
// 1. 找到当前正在发送视频的 RTCRtpSender
const senders = this.peerConnection.getSenders();
const videoSender = senders.find(sender => sender.track && sender.track.kind === 'video');
if (!videoSender) {
console.warn('未找到视频发送器,可能尚未添加轨道');
// 可能第一次共享,直接添加轨道
// return this._addNewTrack(newStream);
}
// 2. 获取新的屏幕共享流
let newStream;
try {
newStream = await navigator.mediaDevices.getDisplayMedia(this._buildConstraints());
} catch (err) {
console.error('获取新共享源失败:', err);
return; // 用户取消了选择
}
const newVideoTrack = newStream.getVideoTracks()[0];
// 3. 核心:替换轨道
try {
await videoSender.replaceTrack(newVideoTrack);
console.log('RTCRtpSender 轨道替换成功');
} catch (replaceError) {
console.error('替换轨道失败:', replaceError);
// 如果替换失败(例如发送器未初始化),回退方案:移除旧发送器,添加新发送器
// 但这会触发重新协商,可能引起短暂中断
this.peerConnection.removeTrack(videoSender);
this.peerConnection.addTrack(newVideoTrack, newStream);
// 注意:移除和添加轨道会触发 onnegotiationneeded 事件,需要处理重新交换信令
}
// 4. 更新本地状态和预览
this._updateLocalStreamAndPreview(newStream, newVideoTrack);
// 5. 处理旧的流和轨道(重要!释放资源)
this._cleanupOldStream();
// 6. 为新轨道绑定结束事件
newVideoTrack.onended = () => this._onTrackEnded();
}
_updateLocalStreamAndPreview(newStream, newVideoTrack) {
// 更新本地预览
this.previewVideo.srcObject = newStream;
// 更新内部状态
this.currentVideoTrack = newVideoTrack;
// 注意:这里我们直接替换了整个 localStream 引用。
// 更严谨的做法是,如果旧的流里还有音频轨道需要保留,应该合并轨道。
this.localStream = newStream;
}
_cleanupOldStream() {
// 停止旧流中的所有轨道,释放摄像头/屏幕捕获资源
if (this._oldStream) {
this._oldStream.getTracks().forEach(track => {
// 确保不是当前正在使用的轨道
if (track !== this.currentVideoTrack) {
track.stop();
}
});
}
// 将当前的流保存为“旧的”,供下次清理
this._oldStream = this.localStream;
}
_onTrackEnded() {
console.log('屏幕共享轨道被终止(可能是用户点击了浏览器停止按钮)');
this.stopScreenShare();
// 这里可以触发UI更新,通知用户共享已结束
}
// ... 其他方法 ...
}
踩坑提醒: replaceTrack 方法传入 null 可以停止发送该轨道,但更好的做法是处理 track.onended 事件。另外,替换轨道后,接收端会收到一个 RTCPeerConnection.ontrack 事件,但事件中的 streams 数组可能不会自动更新为新的流对象,接收端可能需要手动将新的轨道关联到对应的视频元素上。这是很多开发者会忽略的细节。
5. 性能、兼容性与生产环境避坑指南
理论很美好,但上线后全是坑。下面是我在多年实战中总结的,关于 getDisplayMedia 进阶使用必须注意的几个关键点。
5.1 性能优化:分辨率、帧率与带宽
屏幕共享,尤其是共享动态内容(如视频播放、游戏),对带宽和编码性能要求很高。
- 动态调整约束:不要总是用
ideal: 1920x1080。可以根据网络条件动态调整。使用RTCPeerConnection.getStats()API 监控发送端的码率、丢包率,接收端的延迟、抖动。当网络不佳时,通过videoSender.setParameters()来动态降低最大码率、分辨率或帧率。const params = videoSender.getParameters(); if (params.encodings) { params.encodings[0].maxBitrate = 500000; // 降低为500kbps videoSender.setParameters(params).catch(console.error); } - 帧率选择:对于文档、网页共享,15-24fps足够流畅且节省资源。对于演示动画或视频,可能需要30fps。在约束中设置
frameRate: { max: 15 }是一个好习惯。 - 内容感知:如果检测到共享的窗口是静态的(如文档),可以尝试通知编码器使用更低的码率或切换到SVC(可伸缩视频编码,如果浏览器支持)模式,以提升弱网下的体验。
5.2 兼容性处理:不同浏览器的“怪癖”
- Firefox:对
displaySurface约束的支持与Chrome略有不同,且其选择器UI有自己的逻辑。务必在Firefox上测试你的类型选择功能。 - Safari: Safari 对
getDisplayMedia的支持相对较晚,且约束选项可能有限。必须进行特性检测。 - 移动端浏览器:绝大多数移动端浏览器不支持
getDisplayMedia。你的应用必须有优雅降级方案,比如提示用户使用桌面端,或切换到摄像头共享。
健壮的特性检测和错误处理代码:
async function startAdvancedScreenShare() {
// 1. 检测浏览器是否支持
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
alert('您的浏览器不支持屏幕共享功能,请使用最新版的Chrome、Edge、Firefox或Safari。');
return;
}
// 2. 准备约束,考虑兼容性
const constraints = {
video: {}
};
// 尝试应用高级约束,但做好回退准备
if (this.shareType === 'window') {
// 检查浏览器是否可能支持displaySurface约束
// 注意:没有直接的方法检测,我们通过捕获错误来判断
constraints.video.displaySurface = 'window';
}
// 3. 调用并捕获所有可能的错误
try {
const stream = await navigator.mediaDevices.getDisplayMedia(constraints);
// 成功后的逻辑...
} catch (error) {
console.error('捕获屏幕失败:', error.name, error.message);
// 处理用户拒绝权限
if (error.name === 'NotAllowedError') {
alert('您拒绝了屏幕共享权限。如需使用,请在浏览器设置中重新授权。');
return;
}
// 处理选择器被取消(某些浏览器)
if (error.name === 'AbortError' || error.name === 'NotFoundError') {
console.log('用户取消了共享选择');
return;
}
// 处理设备或系统层面的错误
if (error.name === 'NotReadableError' || error.name === 'OverconstrainedError') {
alert('无法访问屏幕内容,可能是由于其他程序正在占用或系统限制。');
return;
}
// 其他未知错误
alert(`屏幕共享启动失败: ${error.message}`);
}
}
5.3 用户体验与隐私安全
- 清晰的指示:在调用
getDisplayMedia之前,一定要用文字或图标明确告知用户“接下来会请您选择要共享的窗口”。减少用户的困惑和误操作。 - 提供“停止共享”的便捷入口:除了浏览器自带的停止按钮,你必须在自己的UI上提供一个醒目且容易点击的“停止共享”按钮,其功能就是调用
track.stop()。 - 共享状态指示器:当共享开始时,在页面上显示一个明显的状态指示器(例如“正在共享中...”),最好能缩略显示正在共享的内容(可以用一个小的
canvas定期从视频流中截图),让共享者自己确认正在共享什么。 - 处理窗口最小化/切换:当被共享的窗口最小化或被其他窗口完全覆盖时,接收端看到的画面可能会停止更新、变黑或显示为占位图(取决于操作系统和浏览器)。这是一个已知的系统和浏览器行为,你需要在产品说明中告知用户。有极客方案尝试用
Worker和canvas捕获来绕过,但复杂且兼容性差,不推荐生产环境使用。
把这些细节都考虑到并处理好,你的多窗口屏幕共享功能才能真正称得上“进阶”,才能在各种复杂的真实场景下稳定、可靠、优雅地工作。从简单的API调用,到精细化的用户体验控制,这中间的每一步都需要你对WebRTC和浏览器媒体模型有更深的理解。希望这篇长文能帮你省去我当年踩过的那些坑。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)