浏览器投屏是很多人都需要的能力:会议中把浏览器里的 PPT 投到会议室屏幕,家里追剧时想从电脑投到电视,直播调试时希望把页面画面分享给另一台设备。Chrome、Edge、Safari 都提供了一些原生投射能力,但这些能力往往绑定各自的协议和生态,用户真正需要的,是一个能覆盖三大浏览器的开源插件,让屏幕捕获、媒体传输、设备展示都由自己控制。本文以 WebRTC 为主要传输链路,实现一个可运行的最小浏览器投屏扩展,覆盖扩展骨架、屏幕捕获、SDP 协商、三端兼容和问题排查,适合想开发投屏工具、研究浏览器扩展,或准备把插件开源发布到商店的开发者。
1. 先理解浏览器投屏插件的本质:不是“一键魔法”,而是一条媒体链路
1.1 原生投射能力和开源扩展的差别
很多人误以为浏览器自带“投屏”按钮就够了。实际上 Chrome、Edge、Safari 的原生投屏能力是各走一路的:
| 浏览器 | 内置投屏能力 | 主要限制 |
|---|---|---|
| Chrome | 投射到 Chromecast 设备 | 需要电视/盒子支持 Cast 协议,Chrome 只负责发现和发送,不适合跨协议投屏 |
| Edge | 投射到 Miracast、DLNA 设备 | Miracast 依赖 Windows 图形驱动和显卡,DLNA 支持面并不稳定 |
| Safari | AirPlay 投递到 Apple TV、支持 AirPlay 的电视 | 绑定 Apple 设备生态,Windows、Android 浏览器不可用 |
原生方案的问题不是“能不能投”,而是“能不能统一”。不同协议之间的设备发现、媒体格式、连接方式各不相同,用户在一台普通电视上可能无法用浏览器直接投屏。开源投屏插件把协议适配、设备发现、用户界面、接收端播放都收拢到一套代码里,既能在三大浏览器安装,也可以由开发者自行扩展目标设备。
1.2 浏览器里常见的投屏协议一共四类
在实际的开源投屏插件中,常见协议可以按传输链路分成四类:
| 协议方向 | 代表技术 | 核心思路 | 适合场景 |
|---|---|---|---|
| WebRTC | 浏览器对浏览器、浏览器对 App | 通过 P2P 协商传输媒体流,延迟低,无需私有硬件 | 浏览器之间投屏、会议直播、屏幕共享 |
| DLNA / UPnP | 家庭局域网媒体设备 | 通过 SSDP 发现设备,再按 UPnP 规范推送媒体 URL | 智能电视、电视盒子、投影仪 |
| Cast | Chromecast / Google TV | 使用 Cast SDK 建立会话,接收端播放媒体 | 支持 Cast 的电视盒子 |
| AirPlay | Apple 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 + Xcode | macOS Monterey 以上,Xcode 14 以上 | 将 Web Extension 转制成 Safari 扩展 |
| Node.js | 16 以上 | 后续跑 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 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 的加载方式几乎一样:
- 打开
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 发送端扩展:捕获屏幕并生成完整 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 / Edge | Safari |
|---|---|---|
navigator.mediaDevices.getDisplayMedia | 支持 | 支持,需用户授权 |
RTCPeerConnection | 支持 | 支持 |
chrome.tabs.create | 支持 | 使用browser.tabs.create |
chrome.tabCapture或browser.tabCapture | Chrome 支持 | 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 审核流程。这里不要试图绕过签名或系统限制,按苹果官方渠道处理即可。