使用 Captured Surface Control API

本指南介绍如何使用 Captured Surface Control API 提供的功能来控制由屏幕捕获 API捕获的显示表面(浏览器标签页、窗口或屏幕)。

Background

屏幕捕获 API 最常用于在会议应用中与其他参与者共享设备上的另一个打开的标签页或窗口,例如演示新功能或展示报告。

这有一个显著的问题是,当您想与捕获的显示表面交互时,例如滚动或缩放它,您必须切换到捕获的显示表面才能进行操作。这会造成几个问题,并使应用程序比必要时更令人沮丧。屏幕共享用户会发现自己不得不在会议应用程序和捕获的显示表面之间来回切换,以调整媒体显示、允许迟到的用户加入、阅读聊天消息等。

Captured Surface Control API 通过允许应用程序开发人员实现一组有限的功能来解决这些问题,会议参与者可以直接在应用程序内使用这些功能来控制捕获的显示表面,而不会危及安全性。

目前这些功能包括

  1. 缩放捕获的显示表面。
  2. 使用鼠标滚轮/触摸板手势(及其他等效操作)滚动捕获的显示表面。

所有这些功能都通过 CaptureController 对象进行访问。要控制捕获的显示表面,必须在 MediaDevices.getDisplayMedia() 调用的 options 对象中传入一个 capture controller。

js
controller = new CaptureController();

const displayMediaOptions = {
  controller,
};

videoElem.srcObject =
  await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);

然后可以使用该 controller 来,例如,放大捕获的显示表面。

js
controller.increaseZoomLevel();

在本文中,我们将逐步介绍一个基本屏幕共享应用程序的代码,该应用程序展示了如何实现这些功能。

关于权限的说明

网站可以使用 Permissions-Policy captured-surface-control 指令,或等效的 <iframe> allow 属性值来控制对 Captured Surface Control API 的访问。

html
<iframe allow="captured-surface-control" src="/some-other-document.html">
  ...
</iframe>

具体来说,forwardWheel()increaseZoomLevel()decreaseZoomLevel()resetZoomLevel() 方法受此指令控制。

captured-surface-control 的默认允许列表是 self,它允许同一来源内的任何内容使用 Captured Surface Control。

如果网站策略允许权限,用户可以授予(或拒绝)访问受控 API 的权限。这可以是显式权限,通过响应提示授予,也可以是通过与调用其中一个方法(瞬时激活)的控件交互而隐式授予,前提是用户未明确拒绝权限。

另请参阅 屏幕捕获 API > 安全注意事项

应用 HTML

我们示例应用程序的标记如下:

html
<h1>Captured Surface Control API demo</h1>

<p>
  <button id="start">Start Capture</button>
  <button id="stop">Stop Capture</button>
</p>
<p id="zoom-controls">
  <button id="dec">Zoom -</button>
  <output>100%</output>
  <button id="inc">Zoom +</button>
  <button id="reset">Reset zoom</button>
</p>

<video autoplay></video>

这包含两组 <button> 元素——一组用于开始和停止屏幕捕获,另一组用于控制捕获的显示表面的缩放。后者还包括一个 <output> 元素,用于显示当前的缩放级别。

最后,我们包含一个 <video> 元素来显示捕获的显示表面。

应用 CSS

应用程序的 CSS 非常简约;值得注意的是,我们为 <video> 设置了 100%max-width,使其能够限制在 <body> 内部。当捕获的显示表面嵌入到其中时(其大小是捕获的固有大小),<video> 可能会急剧增长,如果不加以限制,可能会导致溢出问题。

css
body {
  max-width: 640px;
  margin: 0 auto;
}

video {
  max-width: 100%;
}

初始设置

在我们脚本的第一部分,我们定义了设置应用程序所需的变量。

js
// Grab references to the <video> element and zoom controls
const videoElem = document.querySelector("video");
const zoomControls = document.getElementById("zoom-controls");

// Grab references to the start and stop capture buttons
const startBtn = document.getElementById("start");
const stopBtn = document.getElementById("stop");

// Grab references to the zoom out, in, and reset buttons,
// and the zoom level output
const decBtn = document.getElementById("dec");
const outputElem = document.querySelector("output");
const incBtn = document.getElementById("inc");
const resetBtn = document.getElementById("reset");

// Define variables to store the controller and the zoom levels
// in, when we later create them
let controller = undefined;
let zoomLevels = undefined;

然后,我们通过将表面控件栏的 display CSS 属性设置为 none 来初始隐藏它,并通过将停止按钮的 disabled 属性设置为 true 来禁用它。这些控件在捕获开始之前是不相关的,因此我们不希望在开始时显示它们而混淆用户。

js
zoomControls.style.display = "none";
stopBtn.disabled = true;

控制屏幕捕获

接下来,我们向开始和停止按钮添加 click 事件监听器(使用 EventTarget.addEventListener()),以便在按下它们时开始和停止屏幕捕获。

js
startBtn.addEventListener("click", startCapture);
stopBtn.addEventListener("click", stopCapture);

startCapture() 函数用于开始屏幕捕获,如下所示。我们首先创建一个新的 CaptureController,并将其与 displaySurface 约束一起,作为 MediaDisplayOptions 对象的一部分传递,该约束导致应用程序推荐共享浏览器标签页。

现在是捕获媒体的时候了;我们使用 MediaDevices.getDisplayMedia() 调用来完成此操作,我们将选项传递给它,并将返回的 promise 设置为 <video> 元素的 srcObject 属性的值。当它解析时,我们通过调用 CaptureController.resetZoomLevel() 并将 <output> 元素的内容设置为 100% 来继续执行函数。这并非严格必需,但在捕获标签页时发现它已经被缩小或放大可能会有点令人困惑。在捕获时将缩放级别设置为 100% 感觉更合乎逻辑。这些代码行处理了应用程序在未按“停止捕获”的情况下刷新,然后再次启动捕获的情况。

下一步,我们调用 CaptureController.getSupportedZoomLevels() 来检索捕获的显示表面支持的缩放级别,并将返回的数组存储在 zoomLevels 变量中。

接下来,我们使用 controller 的 zoomlevelchange 事件来检测缩放级别何时发生变化,将当前的 zoomLevel 写入 <output> 元素,并调用用户定义的 updateZoomButtonState() 函数。此函数将查询 zoomLevels 数组,以检查用户在每次缩放更改后是否还可以进一步缩放或缩小。稍后我们将解释 updateZoomButtonState()

我们接下来使用 display: block 来取消隐藏缩放控件,启用停止按钮,并禁用开始按钮,以便在捕获开始后控件的状态变得有意义。

为了完成函数,我们调用 CaptureController.setFocusBehavior() 来阻止焦点在捕获开始时转移到捕获的显示表面,并调用我们用户定义的 startForwarding() 函数来启用使用滚轮/触摸板手势滚动捕获的显示表面。稍后我们将解释此函数。

js
async function startCapture() {
  try {
    // Create a new CaptureController instance
    controller = new CaptureController();

    // Options for getDisplayMedia()
    const displayMediaOptions = {
      controller,
      video: {
        displaySurface: "browser",
      },
    };

    // Capture a tab and display it inside the video element
    videoElem.srcObject =
      await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);

    // Reset the zoom level when capture starts
    controller.resetZoomLevel();
    outputElem.textContent = `100%`;

    // Get zoom levels for the current captured display surface
    zoomLevels = controller.getSupportedZoomLevels();

    // Report zoom level when it changes
    controller.addEventListener("zoomlevelchange", () => {
      outputElem.textContent = `${controller.zoomLevel}%`;
      updateZoomButtonState();
    });

    zoomControls.style.display = "block";
    stopBtn.disabled = false;
    startBtn.disabled = true;

    // Stop the focus from jumping to the captured tab, if you are self-sharing
    controller.setFocusBehavior("focus-capturing-application");

    // Start forwarding wheel events
    startForwarding();
  } catch (e) {
    console.error(e);
  }
}

现在来定义 stopCapture() 函数,该函数用于停止屏幕捕获。我们在函数开始时再次调用 CaptureController.resetZoomLevel() 并将 <output> 元素的内容设置为 100%,以便重置缩放级别。这处理了通过按“停止捕获”来停止捕获然后再次开始捕获的情况。

然后,我们遍历与 MediaStreamstop() 相关的所有 MediaStreamTrack 对象。然后,我们调用 resetApp() 函数,该函数将 <video> 元素的 srcObject 设置回 null,隐藏缩放控件,禁用停止按钮,并启用开始按钮。

js
function stopCapture() {
  let tracks = videoElem.srcObject.getTracks();
  tracks.forEach((track) => track.stop());
  resetApp();
}

function resetApp() {
  videoElem.srcObject = null;
  zoomControls.style.display = "none";
  stopBtn.disabled = true;
  startBtn.disabled = false;
}

实现缩放控件

在我们脚本的下一部分,我们将缩放按钮连接到相应的 click 处理程序函数,以便我们可以放大和缩小捕获的显示表面。它们被点击时运行的函数如下:

js
decBtn.addEventListener("click", decreaseZoom);
incBtn.addEventListener("click", increaseZoom);
resetBtn.addEventListener("click", resetZoom);

async function decreaseZoom() {
  try {
    await controller.decreaseZoomLevel();
  } catch (e) {
    console.log(e);
  }
}

async function increaseZoom() {
  try {
    await controller.increaseZoomLevel();
  } catch (e) {
    console.log(e);
  }
}

async function resetZoom() {
  await controller.resetZoomLevel();
}

注意: 通常,最好将 decreaseZoomLevel()increaseZoomLevel() 调用放在 try...catch 块中,因为缩放级别可能被应用程序以外的实体异步更改,这可能会导致抛出错误。例如,用户可能直接与捕获的表面交互来放大或缩小。

当缩放发生变化时,controller 的 zoomlevelchange 事件会触发,这会导致 startCapture() 函数中前面看到的代码运行,将更新的缩放级别写入 <output> 元素,并运行 updateZoomButtonState() 函数以阻止用户缩放过远。

js
controller.addEventListener("zoomlevelchange", () => {
  outputElem.textContent = `${controller.zoomLevel}%`;
  updateZoomButtonState();
});

将滚轮事件转发到捕获的显示表面

之前,在 startCapture() 函数的底部,我们运行了 startForwarding() 函数,该函数允许从捕获应用程序滚动捕获的显示表面。这会运行 CaptureController.forwardWheel() 方法,我们将 <video> 元素的引用传递给它。当返回的 promise 解析时,浏览器开始将 <video> 上触发的所有 wheel 事件转发到捕获的标签页或窗口,以便进行滚动。

js
async function startForwarding() {
  try {
    await controller.forwardWheel(videoElem);
  } catch (e) {
    console.log(e);
  }
}

阻止用户缩放过远

最后,我们定义 updateZoomButtonState() 函数,该函数在前面看到的 zoomlevelchange 事件处理程序函数中运行。这个函数解决的问题是,如果你尝试缩小到低于最低支持缩放级别,或者放大到高于最高支持缩放级别,decreaseZoomLevel()/increaseZoomLevel() 将会抛出 InvalidStateError DOMException

updateZoomButtonState() 函数通过首先确保“缩小”和“放大”按钮都已启用来避免此问题。然后进行两个检查:

  • 如果当前缩放级别(由 CaptureController.zoomLevel 属性返回)等于最低支持缩放级别(存储在 zoomLevels 数组的第一个值中),则禁用“缩小”按钮,以便用户无法进一步缩小。
  • 如果当前缩放级别等于最高支持缩放级别(存储在 zoomLevels 数组的最后一个值中),则禁用“放大”按钮,以便用户无法进一步放大。
js
function updateZoomButtonState() {
  decBtn.disabled = false;
  incBtn.disabled = false;
  if (controller.zoomLevel === zoomLevels[0]) {
    decBtn.disabled = true;
  } else if (controller.zoomLevel === zoomLevels[zoomLevels.length - 1]) {
    incBtn.disabled = true;
  }
}

完成的演示

完成的演示渲染如下: