跳转到内容

技术细节

Danmaku Anywhere 是一个纯前端的浏览器扩展(mv3),通过注入脚本的方式在网页上显示弹幕。

所有的用户数据都保存在浏览器中。由于弹幕数据量一般会大于浏览器可同步的限制,弹幕数据只保存在当前设备上。

此扩展基本只做两件事情:

  1. 获取弹幕
  2. 渲染弹幕

弹幕获取

弹幕数据保存在扩展的IndexedDB中,分为两种类型:用户上传和第三方源。

用户上传

用户上传作为最基本的获取弹幕的方式,保证扩展可以在不联网的情况下使用。

用户上传的弹幕的使用场景有:

  • 导入已有的,其他来源的弹幕
  • 扩展不支持的弹幕源

在能够使用第三方弹幕源的情况下应该不需要用户上传弹幕。

由于弹幕文件似乎没有一个非常通用的格式,扩展只支持导入特定类型的xmljson文件。

xml格式参考了一些弹幕下载工具的导出格式。

json格式为Danmaku Anywhere自定义的格式,如下:

查看json格式
JSON类型定义
interface CustomComment {
mode?: 'ltr' | 'rtl' | 'top' | 'bottom' // default 'ltr'
time: number // the time in seconds the comment should appear
color: string // hex color code
text: string // the comment text
}
interface CustomDanmaku {
comments: CustomComment[] // at least one comment is required
animeTitle: string
// One of the following is required
episodeTitle?: string
episodeNumber?: number
}
type CustomDanmakuList = CustomDanmaku[]
JSON示例
[
{
"comments": [
{
"mode": "rtl",
"time": 10,
"color": "#FF5733",
"text": "Hello World"
}
],
"animeTitle": "Anime Title",
"episodeTitle": "Episode Title"
}
]

第三方源

除了用户上传之外,其他的获取方式均依赖第三方弹幕视频网站。

由于扩展没有后端,所有请求都是在用户的浏览器中发起的。好在扩展可以通过declarativeNetRequest权限来更改请求头从而绕过cors问题。还可以通过host_permissions权限让请求带上用户的cookie,从而获取到登录状态下的搜素结果和弹幕。

从设计原则上来说,扩展尽可能的最小化对第三方网站的请求,只获取必要的数据。本地缓存弹幕数据很大一部分是因为这个原因。

由于搜索结果和弹幕数据都是和用户登录状态相关的,所以扩展会在用户启用第三方弹幕源时检查用户的登录状态。

B站

在启用 B站弹幕源时,扩展会先GET https://www.bilibili.com/以保证cookie正常,然后再确认用户的登录状态,如果未登录则会提示登录。

腾讯

腾讯视频需要的cookie通过javascript注入,所以无法简单通过GET请求来获取。启用时,如果发现cookie缺失,会要求用户手动前往腾讯视频页面获取cookie

渲染弹幕

渲染指在正确的网站的正确的视频上显示弹幕。

匹配网址

扩展安装时会请求所有网站的权限,但并不是所有网站都需要弹幕,因此需要装填配置来告诉扩展哪些网站需要弹幕。

通过白名单的方式,只有在配置中指定的网址模式才会注入脚本并显示弹幕。同时,装填配置可以用来关联网址和其他配置和规则,比如网页适配和弹幕样式(未实装)等。

装填配置中的网址模式会传递给chrome.scripting.registerContentScripts,只有匹配的网址才会注入脚本。

匹配弹幕

弹幕是和视频绑定的,会与视频的播放/暂停同步。扩展始终只会渲染一个视频的弹幕(单个文档的情况),如果存在多个视频,就需要判断在哪个个视频上显示弹幕。

可能出现多个视频的情况有:

  • 广告
  • 视频预览,比如鼠标悬停时自动播放的视频
  • 隐藏的视频,有些网站会使用隐藏的视频元素来实现一些功能

扩展使用简单的算法决定弹幕于哪个视频绑定,如果选择了错误的视频用户依然可以通过装填配置来指定视频元素。

弹幕渲染

渲染弹幕主要的问题是如何保证弹幕始终显示在视频上方。

考虑的最坏情况是视频全屏,一般全屏是通过Top Layer实现的,所以弹幕也需要使用Top Layer,这样才能保证弹幕始终显示在视频上方。

这样做会导致无法实现弹幕互动,例如鼠标悬停、点击等。由于弹幕层在最上层,会遮挡下方的元素,比如视频控件等。如果要和下方的元素交互,就需要给弹幕层设置pointer-events: none,这样可以避免弹幕层拦截点击事件,但也导致弹幕无法接收用户互动。

iframe

内容脚本的注入与否取决于用户定义的装填配置,配置中的网址模式会传递给chrome.scripting.registerContentScripts,只有匹配的网址才会注入脚本。

这个方法对于大多数网站是有效的,特别是各种自架的 SPA 网站。然而后来发现有很多视频网站,尤其是民间的视频网站,会使用iframe来嵌套视频。

那么问题来了。iframe是一个独立的文档,有自己的地址,并且和主文档之间很难进行交互。 如果视频在iframe中,而用户提供的是主文档的地址,那么扩展会因为被注入到主文档中而无法访问iframe中的视频,导致无法显示弹幕。

解决这个问题有两种方法:

  1. 用户手动添加iframe的地址到装填配置中,这样扩展会被注入到iframe中,就可以访问到视频元素了
  2. 将扩展注入到所有文档中,这样扩展就可以访问到所有的视频元素

第一种

v0.16.0 之前的版本采用的是第一种方法。这种方法虽然可以解决问题,但是需要用户手动添加iframe的地址,存在技术门槛。而且这样也存在一些问题:

  • 多个iframe套娃并且网址相似时,只靠网址模式无法精确控制具体注入到哪个iframe中,可能会产生页面中存在多个内容脚本的而发生冲突情况
  • iframe中的脚本无法自动匹配弹幕,这是因为可以用于匹配的信息一般在主文档中,而iframe中的脚本无法访问主文档的元素

第二种

为了解决上述问题,v0.16.0 之后的版本采用的是第二种方法。

具体实现是将内容脚本拆分为两个模块:控件弹幕播放器。控件只注入在主文档中,用于用户交互和控制,而弹幕播放器注入到所有文档中(包括主文档),用于视频检测和弹幕渲染。

也就是说,一个网页中可能存在多个弹幕播放器,但只有一个控件。控件和播放器类似于主从关系,控件会负责弹幕播放器的注入和启动,两边通过chrome.runtime.sendMessage进行通信。

相比第一种方法,这种方法更通用也更易于使用,但也还是有一些问题:

  • 视频在iframe中时无法使用画中画功能(documentPictureInPictureAPI 限制)
  • 如果存在多个iframe,并且多个iframe中都有视频,扩展无法判断哪个视频是用户正在观看的视频,可能会导致弹幕显示在错误的视频上

Shadow DOM

Shadow DOM 会阻止脚本访问元素。

如果一个网站的视频元素位于 Shadow DOM 中,扩展无法直接访问到视频元素,导致无法正常工作。

目前无解,但也没有遇到过这种情况。