2.2 屏幕共享与特殊媒体源 本节摘要:屏幕共享是会议产品的灵魂功能,也是采集家族里环境最复杂的一支:它由操作系统弹窗驱动、权限模型与摄像头不同、还会被用户随手取消。本节讲透 getDisplayMedia 的完整交互流程与边界处理,再介绍 canvas 与 media 元素这两条「自制轨道」的通道——虚拟背景、特效叠加、播放器推流都从这里长出来。屏幕轨道拿到之后,后续链路与摄像头轨道完全同构。 本节的学习收获 阅读完本节,你应当能够: 用 getDisplayMedia 发起屏幕共享,并处理用户在系统弹窗里取消选择的情形; 说清屏幕共享的权限模型与摄像头授权的差异,以及为什么每次共享都要用户亲自选; 尝试采集屏幕音频,并按浏览器支持度做好能力探测;
本节摘要:屏幕共享是会议产品的灵魂功能,也是采集家族里环境最复杂的一支:它由操作系统弹窗驱动、权限模型与摄像头不同、还会被用户随手取消。本节讲透 getDisplayMedia 的完整交互流程与边界处理,再介绍 canvas 与 media 元素这两条「自制轨道」的通道——虚拟背景、特效叠加、播放器推流都从这里长出来。屏幕轨道拿到之后,后续链路与摄像头轨道完全同构。
阅读完本节,你应当能够:
摄像头采集的授权模型是「问一次,管一段」:用户点头之后,网页就能持续使用设备。屏幕共享完全不同——即便你授权过这个网站,每一次点「共享屏幕」,操作系统都会弹出选择器让你亲自挑:整个屏幕、某个窗口,还是某个标签页,选完才开始,选中途还能随手取消。
这不是浏览器的任性,而是隐私的必然:屏幕上什么都有——聊天窗口、密码管理器、还没发的邮件。把「选什么」的决定权收归用户每一次亲手操作,是这类采集能与摄像头共存于同一套 API 的前提。对开发者的含义是:共享随时可能开始、随时可能被取消、随时可能换内容,代码必须把这三件「随时」都接住。
发起一次屏幕共享并接住所有边界:
async function startShare() { try { const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15, max: 15 } }, audio: true, // 请求系统声音,是否给由浏览器与用户决定 }); const track = stream.getVideoTracks()[0]; // 用户在系统选择器里点了「停止共享」或浏览器停止按钮 track.addEventListener('ended', () => { updateShareUI(false); // 界面状态必须回退,否则按钮是坏的 }); updateShareUI(true); return stream; } catch (err) { if (err.name === 'NotAllowedError') { // 用户在弹窗里点了取消——高频路径,不是错误,安静收场即可 return null; } throw err; } }
三个语义细节决定代码质量。第一,用户取消选择抛出的是 NotAllowedError,与摄像头权限被拒同名——但对屏幕共享而言这是一条正常路径,应当静默返回而不是弹「权限错误」吓用户。第二,audio: true 请求的是系统音频,能否真给取决于浏览器与操作系统:桌面端 Chrome 在选「整个屏幕」时通常可给,选「标签页」时给的是标签页声音,Safari 则长期不支持。拿到 stream 后要检查有没有 audio 轨,而不是假定有。第三,部分浏览器支持在共享过程中用 setDisplaySurfacePreference 或观察 track 内容变化来感知「用户换了共享目标」,但通用做法是监听配置变化事件并对画面比例突变保持宽容。

canvas 的 captureStream 把一块画布变成一条活的视频轨,这是虚拟背景、美颜、水印的通用底座:
// 画面合成:摄像头帧画进画布,叠加水印,再产出合成轨道 const source = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 } }, }); const video = document.createElement('video'); video.srcObject = source; await video.play(); const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480; const ctx = canvas.getContext('2d'); (function draw() { ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.font = '16px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fillText('内部会议 · 请勿外传', 20, canvas.height - 20); requestAnimationFrame(draw); })(); const composedTrack = canvas.captureStream(30).getVideoTracks()[0]; // composedTrack 与摄像头轨道用法完全相同:可预览、可 addTrack、可 replaceTrack
这段代码里藏着两个工程决策。其一,合成是在 GPU 之外用 2D 上下文逐帧画的,CPU 有感知,帧率别贪高;如果要做真正的虚拟背景(人像分割),分割模型本身也要算力,两者叠加时建议把画布分辨率控制在 480p 一档。其二,requestAnimationFrame 在标签页切到后台会被节流到极低的频率——共享画布轨道时如果用户切走了标签页,对端看到的画面就会冻住。解决办法是用定时器驱动绘制,或接受「后台降帧」并告知用户。
media 元素的 captureStream 则用于「把播放器内容推给别人」:本地视频文件、实时合成的音频混音,都能变成轨道。音频轨道同样可以制造——用 Web Audio 的目的地节点接出处理后的混音轨,是把多路声音合并发送的标准做法。
背景:某会议产品要求:老师共享屏幕时,学生端画面以屏幕内容为主,但老师人像要缩成小窗叠在角上,符合「讲义加人像」的授课形态。
操作:老师端同时持有两路轨道(人像、屏幕)。方案没有用 SFU 端合成,而是在浏览器里做画布合成:建一块与屏幕分辨率同比例的画布,每帧先画屏幕轨道(经一个隐藏的 video 元素中转),再把人像轨道缩放画到右下角,最后只把画布产出的这一条合成轨 addTrack 进连接。学生端无需任何改动,收到的就是一张合成好的画面。
结果:学生端兼容性最好——不支持画中画布局的老旧内嵌浏览器也能正常观看;老师端 CPU 占用上升了约一档,但在 480p 人像加 720p 屏幕的组合下仍可接受。
解读:这是「在哪里合成」的经典权衡:客户端合成省服务器算力、学生端零改动,但吃老师端 CPU 且屏幕一高分辨率就吃力;SFU 端合成反过来,服务器贵了但端上轻。产品选客户端合成的决定性因素是「学生端不可控」——教育场景里学生用的设备五花八门,把复杂度留在老师端(设备通常较好)是划算的。
变式:如果老师中途想切回「双画面并排」布局,重新合成需要换一条轨道,这时 2.3 节的 replaceTrack 就登场了:新合成轨替换旧合成轨,学生端画面无感切换,协商都不用重新做。布局策略与轨道替换是天然搭档。