17、屏幕共享切换:切换共享窗口/标签页/整个屏幕、动态替换轨道
屏幕共享做到一半,用户突然说:「哎,我想换个窗口共享。」
或者更常见的场景——演示到一半,想从「整个屏幕」切到「某个标签页」。
嗯,这个需求在远程会议、在线教育、游戏直播里太常见了。我最早做这块的时候,第一反应是「重新调一次 getDisplayMedia 不就行了?」结果发现——每次调用浏览器都会弹出那个选择框,用户得重新选一次,体验非常割裂。
后来我琢磨明白了:真正的「切换」不是重新采集,而是动态替换轨道。
17.1 核心思路:轨道替换 vs 重新采集
先说结论:不要重新创建 PeerConnection,不要重新协商 SDP。你只需要把本地流里的视频轨道换掉,然后触发一次 negotiationneeded 就行。
为什么?
- 重新采集意味着用户要再点一次「选择窗口」,体验差
- 重新创建连接意味着 ICE 要重新建立,有延迟
- 轨道替换是浏览器原生支持的,性能开销极小
核心原则:只换轨道,不换连接。
我个人的习惯是:永远维护一个「当前活跃的 MediaStream」对象,切换时只替换这个流里的视频轨道,然后让 RTCPeerConnection 感知到变化。
17.2 动态替换轨道的标准做法
浏览器提供了 RTCRtpSender.replaceTrack() 这个方法。它允许你在不重建连接的情况下,把发送端正在发送的轨道换成另一个轨道。
来看代码:
// 假设 pc 是已有的 RTCPeerConnection
// sender 是之前 addTrack 时拿到的 RTCRtpSender
async function switchScreen(newStream) {
const videoTrack = newStream.getVideoTracks()[0];
if (!videoTrack) {
console.warn('新流里没有视频轨道');
return;
}
// 找到视频发送器
const sender = pc.getSenders().find(s => s.track.kind === 'video');
if (!sender) {
console.error('没有找到视频发送器');
return;
}
// 替换轨道
await sender.replaceTrack(videoTrack);
// 通知远端:我的轨道变了
// 注意:replaceTrack 会自动触发 negotiationneeded
// 但有些浏览器需要手动触发
console.log('轨道替换成功');
}
这里有个细节:replaceTrack 调用之后,浏览器会自动触发 negotiationneeded 事件。但我在 Chrome 和 Firefox 上都遇到过「触发不及时」的情况。所以我建议:手动调用一次 createOffer + setLocalDescription + 发送给远端。
小技巧:替换轨道后,最好把旧的轨道停掉,释放资源。调用 oldTrack.stop() 可以告诉浏览器「这个摄像头/屏幕采集我不用了」。
17.3 切换共享源:三种场景
用户可能想从「整个屏幕」切到「某个窗口」,或者从「标签页」切到「整个屏幕」。这三种场景的处理逻辑其实是一样的,只是 getDisplayMedia 的 constraints 不同。
| 共享类型 | constraints 设置 | 注意事项 |
|---|---|---|
| 整个屏幕 | { video: true } |
用户选择「整个屏幕」即可 |
| 应用窗口 | { video: { displaySurface: 'window' } } |
部分浏览器不支持指定类型 |
| 浏览器标签页 | { video: { displaySurface: 'browser' } } |
标签页共享有音频选项 |
实际切换时,我会这样写:
async function switchToNewSource(sourceType) {
// 1. 停止旧轨道
const oldSender = pc.getSenders().find(s => s.track.kind === 'video');
if (oldSender && oldSender.track) {
oldSender.track.stop();
}
// 2. 重新采集
const constraints = {
video: {
displaySurface: sourceType // 'monitor' | 'window' | 'browser'
}
};
const newStream = await navigator.mediaDevices.getDisplayMedia(constraints);
// 3. 替换轨道
const newTrack = newStream.getVideoTracks()[0];
await oldSender.replaceTrack(newTrack);
// 4. 触发协商
await doNegotiation();
}
嗯,这里要注意:重新调用 getDisplayMedia 会再次弹出选择框。这是浏览器的安全策略,没办法绕过。但至少连接没有断,远端不会看到黑屏或卡顿。
17.4 避坑指南:我踩过的几个坑
我曾经在做一个远程协作工具时,被轨道切换坑了好几天。分享几个典型问题:
- 坑一:replaceTrack 后远端没反应
原因:没有触发重新协商。解决方案:手动调用 createOffer 并发送。 - 坑二:切换后画面卡住最后一帧
原因:旧轨道没有 stop,浏览器还在尝试读取旧流。解决方案:显式调用 track.stop()。 - 坑三:切换后音频丢失
原因:屏幕共享的音频轨道没有一起替换。解决方案:检查新流是否有 audio track,如果有,一并替换。 - 坑四:用户点了「取消」选择框
原因:getDisplayMedia 被拒绝。解决方案:捕获错误,恢复旧轨道。
重要提醒:getDisplayMedia 返回的流,在用户取消选择时会抛出一个 NotAllowedError。一定要 try-catch 包裹,否则整个应用会崩溃。
17.5 完整切换流程:从用户点击到远端看到新画面
我把整个流程画了一张图,方便你理解:
说白了,整个流程就是:停旧 → 采新 → 替换 → 协商。每一步都有对应的代码和注意事项。
17.6 进阶:无弹窗切换(仅限扩展程序)
如果你开发的是 Chrome 扩展程序,可以绕过 getDisplayMedia 的弹窗限制。通过 chrome.desktopCapture.chooseDesktopMedia API,你可以指定共享源,甚至做到「静默切换」。
不过这个 API 只对扩展程序开放,普通网页用不了。我曾在内部工具里用过一次,体验确实好——用户不需要反复点选,但开发成本也高了不少。
个人建议:普通 Web 应用不要追求「无弹窗切换」。浏览器的安全策略是有道理的——用户应该知道你在共享什么。强行绕过反而会让用户觉得不安全。
17.7 总结:记住这三点
- 不要重建连接:用 replaceTrack 替换轨道,性能好、延迟低
- 记得触发协商:替换后手动 createOffer,确保远端收到新轨道
- 处理异常情况:用户取消选择、轨道获取失败,都要有兜底逻辑
屏幕共享切换这个功能,说难不难,说简单也不简单。核心就一句话:只换轨道,不换连接。把这个原则记在心里,遇到再复杂的切换场景你都能从容应对。
公众号:蓝海资料掘金营,微信deep3321