☰
跨浏览器投屏扩展开发:WebRTC实现屏幕捕获与SDP协商
2026/10/24 7:23:09 网站建设 项目流程

浏览器投屏是很多人都需要的能力:会议中把浏览器里的 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.js

sender.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 charset="utf-8"> <style> body { width: 260px; font-family: sans-serif; } button { width: 100%; padding: 8px; } </style> </head> <body> <h3>Open Browser Cast</h3> <button id="openSender">打开投屏控制台</button> <script src="popup.js"></script> </body> </html>
document.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 的加载方式几乎一样:

  1. 打开chrome://extensions或edge://extensions。
  2. 右上角打开“开发者模式”。
  3. 点击“加载已解压的扩展程序”。
  4. 选择项目根目录,也就是包含manifest.json的目录。
  5. 确认扩展卡片出现,并且没有红色错误提示。

检查点有三个:

  • 扩展图标是否出现在浏览器右上角。
  • 点击图标后弹窗是否正常打开。
  • 点击“打开投屏控制台”后,是否能打开sender.html页面。

如果加载失败,点击扩展卡片上的“错误”按钮即可查看具体报错。最常见问题是manifest.json里多写了尾逗号,或加载目录选错了层级,导致找不到manifest.json。

3. 实现最小投屏链路:捕获 -> WebRTC -> 接收端播放

3.1 总体链路与信令策略

完整投屏链路分成四步:

  1. 发送端扩展页点击“开始捕获”,调用getDisplayMedia获取屏幕流。
  2. 发送端创建RTCPeerConnection,把屏幕轨道addTrack给连接,然后生成 Offer。
  3. 接收端拿到 Offer 后创建 Answer 回传。
  4. 两端交换完 SDP 后,媒体流自动建立,接收端视频播放画面。

SDP 交换需要一台信令服务器或人工辅助。为了先跑通原理,本文使用“手动信令”:发送端复制完整 Offer 到接收端,接收端生成 Answer 后复制回发送端。这样做的好处是不用先搭服务端,坏处是在真实局域网设备上使用不方便。第 3.4 节会再改成 WebSocket 自动信令。

完整 Offer 和 Answer 要包含已经收集到的 ICE candidate,所以不能createOffer后立刻复制。这里等待iceGatheringState变为complete后再输出 SDP,避免后期 trickle candidate 没有交换导致黑屏。

3.2 发送端扩展:捕获屏幕并生成完整 Offer

sender.html主要包含预览视频、开始按钮、Offer 输出区、Answer 粘贴区:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Cast 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 id="preview" autoplay playsinline muted></video> <div class="row"> <button id="startCapture">开始捕获</button> <button id="reset">重置连接</button> </div> <div class="row"> <label>完整 Offer</label> <textarea id="offerOutput" readonly></textarea> <button id="copyOffer">复制 Offer</button> </div> <div class="row"> <label>接收端 Answer</label> <textarea id="answerInput" placeholder="粘贴接收端生成的完整 Answer"></textarea> <button id="applyAnswer">应用 Answer</button> </div> <pre id="status">当前状态:未开始</pre> <script src="sender.js"></script> </body> </html>

sender.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 charset="utf-8"> <title>Cast 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 id="receiverVideo" autoplay playsinline></video> <div class="row"> <label>发送端 Offer</label> <textarea id="offerInput" placeholder="粘贴发送端生成的完整 Offer"></textarea> <button id="applyOffer">应用 Offer</button> </div> <div class="row"> <label>完整 Answer</label> <textarea id="answerOutput" readonly></textarea> <button id="copyAnswer">复制 Answer</button> </div> <pre id="status">等待 Offer...</pre> <script src="receiver.js"></script> </body> </html>

receiver/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 / EdgeSafari
navigator.mediaDevices.getDisplayMedia支持支持,需用户授权
RTCPeerConnection支持支持
chrome.tabs.create支持使用browser.tabs.create
chrome.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 中。

大致流程是:

  1. 在 macOS 上安装 Xcode。
  2. 打开 Xcode,创建一个基于 Safari Web Extension 模板的工程。
  3. 把浏览器扩展源码目录导入工程。
  4. 设置一个 App Group 和开发者签名。
  5. 点击 Run,让 Xcode 把扩展壳安装到系统中,并在 Safari 中启用该扩展。

本地调试时,可能需要打开 Safari 的“开发”菜单,并启用“允许无签名扩展”之类的调试选项。正式上架 Safari 扩展需要 Apple Developer 账号,并走 App Store 审核流程。这里不要试图绕过签名或系统限制,按苹果官方渠道处理即可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询