尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

跨浏览器投屏扩展开发:WebRTC实现屏幕捕获与SDP协商

跨浏览器投屏扩展开发:WebRTC实现屏幕捕获与SDP协商 浏览器投屏是很多人都需要的能力会议中把浏览器里的 PPT 投到会议室屏幕家里追剧时想从电脑投到电视直播调试时希望把页面画面分享给另一台设备。Chrome、Edge、Safari 都提供了一些原生投射能力但这些能力往往绑定各自的协议和生态用户真正需要的是一个能覆盖三大浏览器的开源插件让屏幕捕获、媒体传输、设备展示都由自己控制。本文以 WebRTC 为主要传输链路实现一个可运行的最小浏览器投屏扩展覆盖扩展骨架、屏幕捕获、SDP 协商、三端兼容和问题排查适合想开发投屏工具、研究浏览器扩展或准备把插件开源发布到商店的开发者。1. 先理解浏览器投屏插件的本质不是“一键魔法”而是一条媒体链路1.1 原生投射能力和开源扩展的差别很多人误以为浏览器自带“投屏”按钮就够了。实际上 Chrome、Edge、Safari 的原生投屏能力是各走一路的浏览器内置投屏能力主要限制Chrome投射到 Chromecast 设备需要电视/盒子支持 Cast 协议Chrome 只负责发现和发送不适合跨协议投屏Edge投射到 Miracast、DLNA 设备Miracast 依赖 Windows 图形驱动和显卡DLNA 支持面并不稳定SafariAirPlay 投递到 Apple TV、支持 AirPlay 的电视绑定 Apple 设备生态Windows、Android 浏览器不可用原生方案的问题不是“能不能投”而是“能不能统一”。不同协议之间的设备发现、媒体格式、连接方式各不相同用户在一台普通电视上可能无法用浏览器直接投屏。开源投屏插件把协议适配、设备发现、用户界面、接收端播放都收拢到一套代码里既能在三大浏览器安装也可以由开发者自行扩展目标设备。1.2 浏览器里常见的投屏协议一共四类在实际的开源投屏插件中常见协议可以按传输链路分成四类协议方向代表技术核心思路适合场景WebRTC浏览器对浏览器、浏览器对 App通过 P2P 协商传输媒体流延迟低无需私有硬件浏览器之间投屏、会议直播、屏幕共享DLNA / UPnP家庭局域网媒体设备通过 SSDP 发现设备再按 UPnP 规范推送媒体 URL智能电视、电视盒子、投影仪CastChromecast / Google TV使用 Cast SDK 建立会话接收端播放媒体支持 Cast 的电视盒子AirPlayApple TV、智能电视通过苹果私有协议传输媒体Apple 生态对开源插件来说WebRTC 是最适合第一步落地的协议。因为 Chrome、Edge、Safari 三个浏览器都支持RTCPeerConnection和getDisplayMedia不需要额外申请 SDK 授权也不需要专门硬件。一个页面只要具备 WebRTC 能力就可以作为接收端展示流画面这也是很多开源浏览器投屏插件能快速做通的最小闭环。1.3 本文技术边界先说清楚本文不写一个能直接投到任意电视上的一键插件而是实现一条可运行、可扩展的浏览器投屏链路发送端浏览器扩展页面捕获屏幕或标签页。协商方式先用手动复制 SDP 的方式跑通再用 WebSocket 信令替换。接收端另一个浏览器页面播放画面。这套链路是投屏插件的“最小公共内核”。接收端只要替换成电视端浏览器、HbbTV 页面或自己写的 App传输层逻辑基本可以复用。如果打算做 DLNA 或 Cast 兼容可以先跑通本文的双浏览器链路再替换协议适配层。2. 从零搭一个跨浏览器扩展骨架2.1 环境准备清单需要准备以下内容项目版本建议用途Chrome较新稳定版即可扩展开发与调试Edge较新稳定版即可验证 Chromium 系兼容性macOS XcodemacOS Monterey 以上Xcode 14 以上将 Web Extension 转制成 Safari 扩展Node.js16 以上后续跑 WebSocket 信令服务Chrome 和 Edge 在扩展加载、Manifest V3、getDisplayMedia体验上非常接近建议先在这两个浏览器跑通再处理 Safari。Safari 不支持像 Chrome 那样直接加载未打包目录需要 Xcode 转制和签名这个过程放在第 4 章专门说明。2.2 扩展目录与 Manifest V3 配置最小项目目录如下open-browser-cast/ ├── manifest.json ├── popup.html ├── popup.js ├── sender.html ├── sender.js └── receiver/ ├── receiver.html └── receiver.jssender.html是扩展内部的投屏控制台receiver.html是接收端页面可以放在同一个目录的receiver子目录里后续也能单独部署到静态文件服务器。manifest.json使用 Manifest V3{ manifest_version: 3, name: Open Browser Cast, version: 0.1.0, description: A minimal open-source browser screen casting extension, action: { default_title: Open Cast Sender, default_popup: popup.html }, icons: { 128: icon.png } }这里没有声明大而全的权限。因为项目核心能力是用户主动点击“开始捕获”并选择屏幕、窗口或标签页这属于浏览器系统级授权不需要在扩展权限里申请activeTab或tabs。如果后续要读取当前标签页 URL、自动填充连接码再按需增加activeTab、storage权限不要提前扩大权限范围。2.3 popup 为什么只负责打开投屏控制台popup.html和popup.js的作用是打开投屏控制台!DOCTYPE html html head meta charsetutf-8 style body { width: 260px; font-family: sans-serif; } button { width: 100%; padding: 8px; } /style /head body h3Open Browser Cast/h3 button idopenSender打开投屏控制台/button script srcpopup.js/script /body /htmldocument.getElementById(openSender).addEventListener(click, () { chrome.tabs.create({ url: chrome.runtime.getURL(sender.html) }); });这里要特别注意不要直接在 popup 里调用getDisplayMedia并持有MediaStream。Chrome 扩展的 popup 生命周期非常短用户一旦点击其他区域popup 页面就会被销毁捕获到的流也会随之断开。生产级投屏工具通常会把控制台做成一个独立扩展页或者使用 Side Panel、独立窗口让传输过程有一个稳定的页面上下文。本文使用chrome.tabs.create打开扩展自带的sender.html就是避免 popup 被关闭导致投屏中断。2.4 在 Chrome 和 Edge 中加载未打包扩展Chrome 与 Edge 的加载方式几乎一样打开chrome://extensions或edge://extensions。右上角打开“开发者模式”。点击“加载已解压的扩展程序”。选择项目根目录也就是包含manifest.json的目录。确认扩展卡片出现并且没有红色错误提示。检查点有三个扩展图标是否出现在浏览器右上角。点击图标后弹窗是否正常打开。点击“打开投屏控制台”后是否能打开sender.html页面。如果加载失败点击扩展卡片上的“错误”按钮即可查看具体报错。最常见问题是manifest.json里多写了尾逗号或加载目录选错了层级导致找不到manifest.json。3. 实现最小投屏链路捕获 - WebRTC - 接收端播放3.1 总体链路与信令策略完整投屏链路分成四步发送端扩展页点击“开始捕获”调用getDisplayMedia获取屏幕流。发送端创建RTCPeerConnection把屏幕轨道addTrack给连接然后生成 Offer。接收端拿到 Offer 后创建 Answer 回传。两端交换完 SDP 后媒体流自动建立接收端视频播放画面。SDP 交换需要一台信令服务器或人工辅助。为了先跑通原理本文使用“手动信令”发送端复制完整 Offer 到接收端接收端生成 Answer 后复制回发送端。这样做的好处是不用先搭服务端坏处是在真实局域网设备上使用不方便。第 3.4 节会再改成 WebSocket 自动信令。完整 Offer 和 Answer 要包含已经收集到的 ICE candidate所以不能createOffer后立刻复制。这里等待iceGatheringState变为complete后再输出 SDP避免后期 trickle candidate 没有交换导致黑屏。3.2 发送端扩展捕获屏幕并生成完整 Offersender.html主要包含预览视频、开始按钮、Offer 输出区、Answer 粘贴区!DOCTYPE html html head meta charsetutf-8 titleCast Sender/title style video { width: 100%; max-width: 800px; background: #000; } textarea { width: 100%; height: 120px; } .row { margin: 12px 0; } /style /head body h1投屏发送端/h1 video idpreview autoplay playsinline muted/video div classrow button idstartCapture开始捕获/button button idreset重置连接/button /div div classrow label完整 Offer/label textarea idofferOutput readonly/textarea button idcopyOffer复制 Offer/button /div div classrow label接收端 Answer/label textarea idanswerInput placeholder粘贴接收端生成的完整 Answer/textarea button idapplyAnswer应用 Answer/button /div pre idstatus当前状态未开始/pre script srcsender.js/script /body /htmlsender.js核心实现let peer null; let localStream null; let previewVideo document.getElementById(preview); let statusBox document.getElementById(status); let offerOutput document.getElementById(offerOutput); let answerInput document.getElementById(answerInput); const ICE_SERVERS [ { urls: stun:stun.l.google.com:19302 } ]; function setStatus(text) { statusBox.textContent 当前状态 text; } function createPeer() { peer new RTCPeerConnection({ iceServers: ICE_SERVERS }); peer.onicegatheringstatechange () { if (peer.iceGatheringState complete) { offerOutput.value JSON.stringify(peer.localDescription); setStatus(ICE 收集完成请复制 Offer 到接收端。); } }; peer.onconnectionstatechange () { setStatus(连接状态 peer.connectionState); }; } async function startCapture() { if (peer) { peer.close(); peer null; } try { localStream await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: true }); } catch (e) { if (e.name TypeError || e.name OverconstrainedError) { localStream await navigator.mediaDevices.getDisplayMedia({ video: true }); } else { throw e; } } previewVideo.srcObject localStream; createPeer(); localStream.getTracks().forEach((track) { peer.addTrack(track, localStream); }); const offer await peer.createOffer(); await peer.setLocalDescription(offer); offerOutput.value ICE 收集中...; setStatus(正在收集 ICE请稍候。); } document.getElementById(startCapture).addEventListener(click, startCapture); document.getElementById(copyOffer).addEventListener(click, async () { await navigator.clipboard.writeText(offerOutput.value); setStatus(Offer 已复制。); }); document.getElementById(applyAnswer).addEventListener(click, async () { const text answerInput.value.trim(); if (!text || !peer) return; const answer JSON.parse(text); await peer.setRemoteDescription(answer); setStatus(Answer 已应用等待媒体流建立。); }); document.getElementById(reset).addEventListener(click, () { if (peer) peer.close(); if (localStream) localStream.getTracks().forEach((t) t.stop()); peer null; localStream null; offerOutput.value ; answerInput.value ; previewVideo.srcObject null; setStatus(已重置。); });关键点解释getDisplayMedia必须由用户手势触发按钮点击事件刚好满足。audio: true用于采集系统声音或标签页声音但不同浏览器支持不同。Chrome 和 Edge 在桌面系统上支持较好Safari 的部分版本需要用户额外授权。捕获失败时代码不会直接结束而是回退到只采集视频。createPeer()里挂了onicegatheringstatechange等本地 ICE 收集完后再输出完整 Offer。这个细节决定了手动信令能否一次连上。如果用户重新点击“开始捕获”先关闭旧peer避免同一轨道被重复添加。3.3 接收端页面把 Offer 变成 Answer 并播放画面receiver/receiver.html只需要一个视频区域、Offer 粘贴区、Answer 输出区!DOCTYPE html html head meta charsetutf-8 titleCast Receiver/title style video { width: 100%; max-width: 800px; background: #000; } textarea { width: 100%; height: 120px; } .row { margin: 12px 0; } /style /head body h1投屏接收端/h1 video idreceiverVideo autoplay playsinline/video div classrow label发送端 Offer/label textarea idofferInput placeholder粘贴发送端生成的完整 Offer/textarea button idapplyOffer应用 Offer/button /div div classrow label完整 Answer/label textarea idanswerOutput readonly/textarea button idcopyAnswer复制 Answer/button /div pre idstatus等待 Offer.../pre script srcreceiver.js/script /body /htmlreceiver/receiver.js核心实现let peer null; let receiverVideo document.getElementById(receiverVideo); let statusBox document.getElementById(status); let offerInput document.getElementById(offerInput); let answerOutput document.getElementById(answerOutput); const ICE_SERVERS [ { urls: stun:stun.l.google.com:19302 } ]; function setStatus(text) { statusBox.textContent text; } function createPeer() { peer new RTCPeerConnection({ iceServers: ICE_SERVERS }); peer.ontrack (event) { receiverVideo.srcObject event.streams[0]; setStatus(已收到远端媒体流。); }; peer.onicegatheringstatechange () { if (peer.iceGatheringState complete) { answerOutput.value JSON.stringify(peer.localDescription); setStatus(Answer 已生成请复制回发送端。); } }; peer.onconnectionstatechange () { setStatus(连接状态 peer.connectionState); }; } document.getElementById(applyOffer).addEventListener(click, async () { const text offerInput.value.trim(); if (!text) return; createPeer(); const offer JSON.parse(text); await peer.setRemoteDescription(offer); const answer await peer.createAnswer(); await peer.setLocalDescription(answer); answerOutput.value ICE 收集中...; setStatus(正在生成 Answer...); }); document.getElementById(copyAnswer).addEventListener(click, async () { await navigator.clipboard.writeText(answerOutput.value); setStatus(Answer 已复制。); });接收端侧两个细节需要注意ontrack在远端轨道路由建立后触发此时把event.streams[0]赋给video.srcObject。如果只赋值event.track会在轨道源切换时出现画面丢失建议绑定流。video元素要加autoplay和playsinline。Safari 对自动播放策略有严格要求虽然applyOffer是用户手势触发的但为了稳妥视频元素仍要保持这两个属性。3.4 手动信令替换成 WebSocket 自动信令手动复制 SDP 适合教学不适合真实投屏。生产级投屏插件至少需要一个轻量信令服务用来交换以下消息{ type: offer, roomId: living-room, payload: { sdp: ..., type: offer } }{ type: answer, roomId: living-room, payload: { sdp: ..., type: answer } }也可以用candidate消息单独传输 trickle candidate{ type: candidate, roomId: living-room, payload: { candidate: ..., sdpMid: 0, sdpMLineIndex: 0 } }Node.js 端只需要维护一个房间与会话的映射转发时按roomId匹配。不要直接把媒体流经过服务器信令服务只负责交换连接信息。媒体流在两端之间走 WebRTC 的 P2P 链路这样服务器压力很小。4. Chrome、Edge、Safari 三端兼容处理和调试4.1 三大浏览器 API 差异同一个扩展在三大浏览器上最大的差异不在getDisplayMedia而在“扩展 API 的命名空间”和“tabCapture 支持程度”。能力Chrome / EdgeSafarinavigator.mediaDevices.getDisplayMedia支持支持需用户授权RTCPeerConnection支持支持chrome.tabs.create支持使用browser.tabs.createchrome.tabCapture或browser.tabCaptureChrome 支持Safari 支持有限加载未打包扩展Chrome / Edge 直接支持需要 Xcode 转制并签名为了避免chrome和browser命名空间差异可以封装一层const bridge globalThis.browser || globalThis.chrome; async function openSenderPage(url) { await bridge.tabs.create({ url }); }如果项目依赖很多chrome.*API建议引入webextension-polyfill。它在后台把browser.*风格的 Promise 兼容层补齐减少条件判断。4.2 Safari 扩展安装和调试的方法Safari 不能像 Chrome 那样“加载已解压的扩展”。Safari Web Extension 需要借助 Xcode 转制成原生扩展壳再运行到 Safari 中。大致流程是在 macOS 上安装 Xcode。打开 Xcode创建一个基于 Safari Web Extension 模板的工程。把浏览器扩展源码目录导入工程。设置一个 App Group 和开发者签名。点击 Run让 Xcode 把扩展壳安装到系统中并在 Safari 中启用该扩展。本地调试时可能需要打开 Safari 的“开发”菜单并启用“允许无签名扩展”之类的调试选项。正式上架 Safari 扩展需要 Apple Developer 账号并走 App Store 审核流程。这里不要试图绕过签名或系统限制按苹果官方渠道处理即可。
返回列表