技术细节
Danmaku Anywhere 是一个纯前端的浏览器扩展(mv3),通过注入脚本的方式在网页上显示弹幕。
所有的用户数据都保存在浏览器中。由于弹幕数据量一般会大于浏览器可同步的限制,弹幕数据只保存在当前设备上。
此扩展基本只做两件事情:
- 获取弹幕
- 渲染弹幕
弹幕获取
弹幕数据保存在扩展的IndexedDB
中,分为两种类型:用户上传和第三方源。
用户上传
用户上传作为最基本的获取弹幕的方式,保证扩展可以在不联网的情况下使用。
用户上传的弹幕的使用场景有:
- 导入已有的,其他来源的弹幕
- 扩展不支持的弹幕源
在能够使用第三方弹幕源的情况下应该不需要用户上传弹幕。
由于弹幕文件似乎没有一个非常通用的格式,扩展只支持导入特定类型的xml
和json
文件。
xml
格式参考了一些弹幕下载工具的导出格式。
json
格式为Danmaku Anywhere自定义的格式,如下:
查看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[]
[ { "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
中的视频,导致无法显示弹幕。
解决这个问题有两种方法:
- 用户手动添加
iframe
的地址到装填配置中,这样扩展会被注入到iframe
中,就可以访问到视频元素了 - 将扩展注入到所有文档中,这样扩展就可以访问到所有的视频元素
第一种
v0.16.0
之前的版本采用的是第一种方法。这种方法虽然可以解决问题,但是需要用户手动添加iframe
的地址,存在技术门槛。而且这样也存在一些问题:
- 多个
iframe
套娃并且网址相似时,只靠网址模式无法精确控制具体注入到哪个iframe
中,可能会产生页面中存在多个内容脚本的而发生冲突情况 iframe
中的脚本无法自动匹配弹幕,这是因为可以用于匹配的信息一般在主文档中,而iframe
中的脚本无法访问主文档的元素
第二种
为了解决上述问题,v0.16.0
之后的版本采用的是第二种方法。
具体实现是将内容脚本拆分为两个模块:控件和弹幕播放器。控件只注入在主文档中,用于用户交互和控制,而弹幕播放器注入到所有文档中(包括主文档),用于视频检测和弹幕渲染。
也就是说,一个网页中可能存在多个弹幕播放器,但只有一个控件。控件和播放器类似于主从关系,控件会负责弹幕播放器的注入和启动,两边通过chrome.runtime.sendMessage
进行通信。
相比第一种方法,这种方法更通用也更易于使用,但也还是有一些问题:
- 视频在
iframe
中时无法使用画中画功能(documentPictureInPicture
API 限制) - 如果存在多个
iframe
,并且多个iframe
中都有视频,扩展无法判断哪个视频是用户正在观看的视频,可能会导致弹幕显示在错误的视频上
Shadow DOM
Shadow DOM 会阻止脚本访问元素。
如果一个网站的视频元素位于 Shadow DOM 中,扩展无法直接访问到视频元素,导致无法正常工作。
目前无解,但也没有遇到过这种情况。