画中画

此组件使得网页上的 <video> 元素可以在始终位于顶层的视频播放器中播放。

本文档涵盖了以下机制的架构和内部工作原理:在始终位于顶层的视频播放器中显示 <video> 的机制,以及显示覆盖 <video> 元素的画中画切换按钮的机制,该机制是启动该功能的主要方法。

高级概述

下图试图说明子组件及其相互交互方式。

../../../../_images/PiP-diagram.svg

假设用户加载了一个包含 <video> 元素的文档,并决定在画中画窗口中打开它。会发生什么?

首先,PictureInPictureToggleChild 组件会注意到何时将 <video> 元素添加到 DOM 中,并监视鼠标在文档中的移动。一旦鼠标与 <video> 相交,PictureInPictureToggleChild 就会使画中画切换按钮出现在该元素上。

如果用户点击该切换按钮,则 PictureInPictureToggleChild 会在视频上分派一个仅限 Chrome 的 MozTogglePictureInPicture 事件,该事件由该文档的 PictureInPictureLauncherChild 参与者处理。通过事件进行间接处理的原因是媒体上下文菜单也可以通过在视频上分派相同的事件来触发画中画。在处理该事件后,PictureInPictureLauncherChild 参与者会向父进程发送 PictureInPicture:Request 消息。父进程会打开始终位于顶层的播放器窗口,并使用一个远程 <xul:browser>,该远程 <xul:browser> 与原始 <video> 运行在同一个内容进程中。然后,父进程会向播放器窗口中加载的远程 <xul:browser> 发送消息。为播放器窗口浏览器内部加载的空文档实例化了一个 PictureInPictureChild 参与者。此 PictureInPictureChild 参与者会构建它自己的 <video> 元素,然后告诉 Gecko 将原始 <video> 的帧克隆到新创建的 <video> 中。

此时,视频将在画中画播放器窗口中显示。

接下来,我们将讨论各个子组件,以及它们如何在更详细的级别上运行。

画中画切换按钮

开发此功能时面临的主要挑战之一是,在实践中,鼠标事件往往无法到达 <video> 元素。这通常是因为 <video> 元素包含在其他 DOM 元素的层次结构中,这些元素捕获并处理任何传入的事件。这通常发生在构建自己的视频控件的网站上。这就是我们不能简单地在 <video> UAWidget 上使用 mouseover 事件处理程序的原因——在执行事件捕获的网站上,我们永远不会收到这些事件,并且切换按钮将无法访问。

其他时候,问题在于视频被半透明或完全透明的元素覆盖,这些元素捕获了原本应该分派到下面的 <video> 的任何鼠标事件。例如,这可能发生在希望在视频暂停时显示覆盖层的网站上。

为了解决此问题,PictureInPictureToggleChild 参与者类每隔 MOUSEMOVE_PROCESSING_DELAY_MS 毫秒对最新的 mousemove 事件进行采样,然后使用 aOnlyVisible 参数调用 nsIDOMWindowUtils.nodesFromRect 以获取位于鼠标光标位置的 1x1 矩形下存在的所有可见节点的完整列表。

如果 <video> 位于该列表中,则我们会进入其阴影根,并更新一些属性以告知它是否可能显示切换按钮。

视频的基本 UAWidget 定义在 videocontrols.js 中,并最终根据以下启发式方法选择是否显示切换按钮

  1. 视频是否少于 45 秒?

  2. 视频的宽度或高度是否小于 160px?

  3. 视频是否静音?

如果上述任何一项为真,则基本 UAWidget 将隐藏切换按钮,因为用户不太可能希望将视频弹出到始终位于顶层的播放器窗口中。

视频注册

从计算的角度来看,每隔 MOUSEMOVE_PROCESSING_DELAY_MS 对最新的 mousemove 事件进行采样不是免费的,因此我们仅在页面上存在一个或多个可见的 <video> 元素时才执行此操作。我们使用 IntersectionObserver 来注意到视口中是否存在 <video>,如果存在一个或多个可见的 <video> 元素,则我们开始对 mousemove 事件进行采样。

通过侦听 UAWidgetSetupOrChange 事件,在将视频添加到 DOM 时将其添加到 IntersectionObserver 中。这被认为是“已注册”。

docState

PictureInPictureChild.sys.mjs 包含一个 WeakMap,它将 document 映射到 PictureInPictureToggleChild 想要在该 document 的生命周期内保留的各种信息。例如,我们是否正在处理用户点击其指针设备。任何需要记住的状态都应添加到 docState WeakMap 中。

点击切换按钮

如果用户点击画中画切换按钮,我们不希望底层网页知道发生了这种情况,因为这可能导致意外行为,例如页面导航(例如,如果 <video> 是一个长时间运行的广告,点击后会导航)。

为了实现这一点,我们在捕获阶段侦听在鼠标点击根窗口期间触发的所有事件。这使我们能够在将事件分派到内容之前处理这些事件。

第一个触发的事件 pointerdown 被捕获,我们检查 docState 以查看我们是否在任何视频上显示切换按钮。如果是,我们检查该切换按钮的坐标与 pointerdown 事件的坐标,以确定用户是否正在点击切换按钮。如果是,我们在 docState 中设置一个标志,以便捕获并抑制点击产生的任何后续事件(如 mousedownmouseuppointerupclick)。如果 pointerdown 事件未发生在切换按钮内,则我们让事件正常传递。

如果我们确定点击发生在切换按钮上,则会在底层的 <video> 上分派 MozTogglePictureInPicture 事件。此事件由单独的 PictureInPictureLauncherChild 类处理。

PictureInPictureLauncherChild

一个小型参与者类,其唯一职责是通过向其父参与者发送 PictureInPicture:Request 消息来告知父进程打开一个始终位于顶层的窗口。

目前,这仅在用户点击画中画切换按钮或使用上下文菜单时,由 PictureInPictureToggleChild 分派仅限 Chrome 的 MozTogglePictureInPicture 事件时发生。

PictureInPictureChild

PictureInPictureChild 演员类将在包含视频的内容进程中运行,并在播放器窗口的 player.js 脚本运行初始化时实例化。一个 PictureInPictureChild 将单个 <video> 映射到一个播放器窗口实例。它创建一个始终位于顶层的窗口,并在该窗口内设置一个新的 <video>,以从另一个 <video> 克隆帧(该帧将在同一进程中,并拥有自己的 PictureInPictureChild)。创建此窗口还会导致创建新的 PictureInPictureChild。此实例将监视源 <video> 的更改,并在用户想要控制 <video> 时接收来自播放器窗口的命令。

PictureInPicture.sys.mjs

此模块在父进程中运行,也是所有 PictureInPictureParent 实例所在的范围。 PictureInPicture.sys.mjs 的工作是向 PictureInPictureChild 实例发送和接收消息,并做出相应的反应。

至关重要的是,PictureInPicture.sys.mjs 负责打开始终位于顶层的播放器窗口,并将有关要显示的 <video> 的相关信息传递给它。

画中画播放器窗口

画中画播放器窗口是一个加载 XHTML 文档的 Chrome 特权窗口。该文档包含一个远程 <browser> 元素,该元素在窗口初始化期间被重新用于加载与源 <video> 相同的内容进程。

播放器窗口是定义播放器控件(如“播放”和“暂停”)的位置。当用户与播放器控件交互时,会向相应的 PictureInPictureChild 发送一条消息,以在源选项卡中底层 <video> 元素上调用相应的方法。

克隆视频帧

虽然看起来视频是从原始的 <video> 元素移动到播放器窗口,但实际上发生的是视频帧被克隆到播放器窗口的 <video> 元素。此克隆在平台级别使用 <video> 元素上的特权方法完成:cloneElementVisually

cloneElementVisually

Promise<void> video.cloneElementVisually(otherVideo);

这将克隆为 video 解码的帧,并将其也显示在 otherVideo 元素上。一旦克隆成功启动,返回的 Promise 就会解析。

stopCloningElementVisually

void video.stopCloningElementVisually();

如果 video 正在视觉上克隆到另一个元素,则调用此方法将停止克隆。

isCloningElementVisually

boolean video.isCloningElementVisually;

一个只读值,如果 video 正在视觉上克隆,则返回 true

特定网站的视频包装器

特定网站的视频包装器允许创建自定义脚本,画中画组件在特定域中加载视频时可以使用这些脚本。目前,视频包装器的一些用途包括

  • 在某些视频流媒体网站上集成字幕和隐藏字幕支持

  • 在使用画中画控件时修复不一致的视频行为

  • 隐藏页面特定区域的视频的画中画切换,给定一个 URL(而不是隐藏页面上所有视频的切换)

PictureInPictureChildVideoWrappervideoWrapperScriptPath

PictureInPictureChildVideoWrapper 是一个表示视频包装器的特殊类。它在 PictureInPictureChild.sys.mjs 中定义,并映射到 videoWrapperScriptPath,它是要使用的自定义包装器脚本的路径。 videoWrapperScriptPathbrowser/extensions/pictureinpicture/data/picture_in_picture_overrides.js 中为某个域定义,自定义包装器脚本在 browser/extensions/pictureinpicture/video-wrappers 中定义。

如果在初始化画中画切换或窗口时检测到 videoWrapperScriptPath,我们将立即根据给定的路径创建一个新的 PictureInPictureChildVideoWrapper 实例,从而允许我们运行自定义脚本。

API

请参阅 API 参考 中的完整方法列表。

沙箱

在源视频上执行视频控制操作需要在浏览器内容中执行代码。出于安全原因,我们使用沙箱来隔离这些操作并防止直接访问 PictureInPictureChild。换句话说,我们在沙箱本身内运行内容代码。但是,有必要放弃 X 光视觉,以便我们可以执行视频控制操作。这是通过读取包装器的 .wrappedJSObject 属性来完成的。

添加新的特定网站视频包装器

创建新的包装器脚本文件

browser/extensions/pictureinpicture/video-wrappers 中为新的视频包装器添加一个新的 JS 文件。为了使包装器工作,该文件必须满足几个要求。

脚本文件要求:

  • 定义的类 PictureInPictureVideoWrapper

  • 分配 this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper

PictureInPictureVideoWrapper 类要求:

重写方法要求:

  • 返回一个类型与 validateRetValPictureInPictureChildVideoWrapper.#callWrapperMethod() 中对应的返回值。

下面是一个脚本文件 mock-wrapper.js 的示例,它重写了 PictureInPictureChildVideoWrapper 中现有的方法 setMuted()

// sample file `mock-wrapper.js`
class PictureInPictureVideoWrapper {
   setMuted(video, shouldMute) {
      if (video.muted !== shouldMute) {
         let muteButton = document.querySelector("#player .mute-button");
         if (muteButton) {
            muteButton.click();
         } else {
            video.muted = shouldMute;
         }
      }
   }
}

this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper

注意

如果需要新的 PictureInPictureChildVideoWrapper 视频控制方法,请参阅 添加新的视频控制方法

声明 videoWrapperScriptPath

browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js 中为该网站声明一个属性 videoWrapperScriptPath

someWebsite: {
  "https://*.somewebsite.com/*": {
    videoWrapperScriptPath: "video-wrappers/mock-wrapper.js",
  },
}

在此示例中,为名为 someWebsite 的网站提供了 URL 模式 https://*.somewebsite.com/*。画中画在初始化时检查是否有任何覆盖,它将加载由 videoWrapperScriptPath 指定的脚本。因此,只要我们从与 somewebsite.com 匹配的 URL 查看视频,位于 video-wrappers/mock-wrapper.js 中的脚本就会运行。

moz.build 中注册新的包装器

我们应该通过添加新创建的包装器的路径来更新 browser/extensions/pictureinpicture/moz.build

FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += [
    "video-wrappers/mock-wrapper.js",
    "video-wrappers/netflix.js",
    "video-wrappers/youtube.js",
]

正如任何 moz.build 文件所预期的那样,顺序很重要。已注册的路径应按字母顺序排列。否则,构建将失败。

添加新的视频控制方法

如果 PictureInPictureChildVideoWrapper 中现有的任何可重写方法都不适用于错误修复或功能增强,我们可以通过调用 #callWrapperMethod() 创建一个新的方法。下面是如何定义新的可重写方法 setMuted() 的示例

// class PictureInPictureChildVideoWrapper in PictureInPictureChild.sys.mjs
setMuted(video, shouldMute) {
    return this.#callWrapperMethod({
        name: "setMuted",
        args: [video, shouldMute],
        fallback: () => {
            video.muted = shouldMute;
        },
        validateRetVal: retVal => retVal == null,
    });
}

新方法传递给 #callWrapperMethod()

  1. 方法名称

  2. 包装器脚本可能使用的预期参数

  3. 回退函数

  4. 验证返回值的条件表达式

如果包装器脚本失败或未重写该方法,则仅执行回退函数。 validateRetVal 检查返回值的类型,并确保它与预期类型匹配。如果没有返回值,只需验证类型是否为 null

注意

首选通用方法名称,以便它们可以用于任何视频包装器。例如:不要将方法命名为 updateCaptionsContainerForSiteA(),而应使用 updateCaptionsContainer()

使用新的视频控制方法

定义新方法后,它可以在整个 PictureInPictureChild.sys.mjs 中使用。在当前示例中,我们调用 PictureInPictureChildVideoWrapper.setMuted() 来静音或取消静音视频。 this.videoWrapperPictureInPictureChildVideoWrapper 的一个实例

// class PictureInPictureChild in PictureInPictureChild.sys.mjs
mute() {
    let video = this.getWeakVideo();
    if (video && this.videoWrapper) {
        this.videoWrapper.setMuted(video, true);
    }
}

unmute() {
    let video = this.getWeakVideo();
    if (video && this.videoWrapper) {
        this.videoWrapper.setMuted(video, false);
    }
}

测试特定网站的视频包装器

自动化测试

特定网站包装器的自动化测试目前有限。可以在 browser/extensions/pictureinpicture/tests/browser 中进行新的测试以确保一般功能,但这些测试仅限于 Firefox Nightly,并且不会测试特定网站上的功能。

编写测试的一些挑战包括

  • 访问 DRM 内容

  • 如果网站需要用户帐户,则需要登录凭据

  • 检测对网页或视频播放器的修改,这些修改使包装器脚本过时

手动测试

目前的首选方法是手动测试视频包装器,并结合 phabricator 组 #pip-reviewers 提供的审查意见。以下是一些审阅者将考虑的问题

  • 画中画是否崩溃或冻结?

  • 包装器是否适用于 Windows、MacOS 和 Linux?

  • 画中画功能是否按预期工作?(画中画切换、文本轨道、视频控件等)

  • 现有的自动化测试是否按预期工作?

警告

DRM 内容可能无法在所有本地 Firefox 版本中加载。一种可能的解决方案是在试用版本(例如 Linux)中测试视频包装器。根据所做的更改,我们可能还需要脚本在临时首选项下运行,例如 media.videocontrols.picture-in-picture.WIP.someWebsiteWrapper,以便在 Firefox Nightly 中测试更改。

API 参考

toolkit/components/pictureinpicture

toolkit/actors/PictureInPictureChild.sys.mjs