5

Bilibili Watchlater Plus : 让 B 站的稍后再看更好用

 2 years ago
source link: https://blog.andiedie.cn/posts/7a6d/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

B 站的稍后再看功能明显是一个半成品,有许多不完善的地方,其中最致命的就是无法加入和播放番剧。最近我终于忍受不了它的诸多问题,着手开发了一个油猴脚本 Bilibili Watchlater Plus,对稍后再看功能进行了一番改造。开源地址:bilibili-watchlater-plus

B 站的稍后再看功能主要有三点让我觉得不能忍受:

  1. 无法将番剧加入稍后再看。

    image-20190125195221887

  2. 即使通过一些特别的方式将番剧加入了稍后再看,也无法播放。

    image-20190125203812434

  3. 稍后再看按钮没有初始状态。

    一句话总结理想状态就是,已加入的视频初始显示勾选,没加入的视频初始不勾选;点击未勾选的按钮,将视频加入稍后再看;点击已勾选的按钮,将视频移出稍后再看

    然后 B 站目前无论一个视频有没有被加入稍后再看,它的按钮都是同一个初始状态,也就是说下面的第一张图。

    没加入稍后再看的视频的默认初始状态:

    image-20190125210925839

    已被加入稍后再看的视频的理想初始状态

    image-20190125211007688

2. 初级阶段

在开发这个正式版本之前,我曾写过一个临时用用的版本,主要作用于首页右上角的动态悬浮窗,因为那是我用的最多的地方。

这个版本可以从这里获取:Bilibili Watchlater Plus 0.0.1

当时解决了在动态悬浮窗上已经可以将番剧加入稍后再看,稍后再看按钮有初始状态。对于在稍后再看中的番剧视频,则是采用直接跳转到播放链接的方式解决。

上述的临时版本让我在一段时间内有了比较舒适的使用体验,但是近段时间 B 站更改了一些 UI 和 API 接口,原来的脚本失效了。正巧假期时间比较充裕,我就借此机会开发了一个比较完整的版本。

3.1. 如何实现

首先考虑各种需求该如何实现。

  1. 让番剧也可以加入稍后再看。

    首先是可行性。B 站的稍后再看 API 需要 aid (即视频 ID)作为参数,而番剧的单集 ID 是 epid。只需要通过番剧播放页就可以获取 epid 对应的 aid,再调用 API 就可以将番剧加入稍后再看。

    然后是如何实现。目前常规视频在封面图右下角都会有一个稍后再看按钮,点击就可以添加或删除稍后再看,然后番剧的封面却没有。因此实现番剧加入稍后再看非常简单,只需要给番剧的封面也添加一个稍后再看按钮就可以了。

  2. 稍后再看按钮的初始状态。

    在之前提到的初级版本里,我解决这个问题的方法非常粗暴:首先通过 API 确定每个视频是否已经加入了稍后再看,对于已经加入稍后再看的视频,给它的按钮添加一个 class 改变样式。

    这样的好处是非常简单粗暴,几行就可以写完所有逻辑。但是坏处也显而易见,按钮仅仅是样式改变了,功能却没有,导致点击一个已勾选的按钮结果却又是将视频加入稍后再看,而不是勾选按钮应该做的”将视频移出稍后再看“。简单的来说就是按钮功能和样式的不统一。

    要实现按钮功能和样式的统一,只能抛弃原有的按钮,自己从头开始为每个视频添加稍后再看按钮。这样按钮的样式、功能都可以自己控制,唯一的缺点就是需要一定的工作量。

  3. 可以在稍后再看播放番剧。

    这个是最让我头疼的地方就是如何在稍后再看中播放番剧。最后我发现 Hack B 站的视频播放器使之能播放番剧太麻烦了,不如我自己实现一个稍后再看的播放逻辑。

    原本的播放逻辑是 B 站在一个专为稍后再看编写的单页面应用中,逐个播放视频,当遇到番剧时就无法解析并弹窗。为了简化开发,我设定的新逻辑是,点击稍后再看的视频直接跳转到常规视频播放页面,并在页面左边添加一个汉堡菜单,可以看到并跳转到其他稍后再看。

3.2. 监听页面变化

上面提到,我们需要替换、添加按钮,但这会遇到几个问题:

  • 由于油猴脚本可能在任何时候插入页面,此时页面的状态不确定
  • 页面可能在任何时候更新,比如加载中的页面或者用户点击导致页面变化

最理想的实现是,监听页面的变化。这里提出一个需求,我需要寻找页面中所有的旧按钮,替换成新的自定义的按钮。利用 HTML 提供的 MutationObserver API,我们可以订阅某个根节点下的所有变化,我们只需要在变化的节点下寻找有没有旧按钮就行了。


但是现在出现了另一个需求:添加按钮。

添加按钮的逻辑是,找到一个父元素,这个父元素原本应该有稍后再看按钮,而现在却没有,那么我们就向父元素里添加自定义的按钮。也就是说我们搜索的检查点有两个,父元素和是否有子元素,只有”找到父元素“和”按钮子元素不存在“同时满足时,才添加自定义按钮。

让我们回想刚刚提到的方法,首先 MutationObserver 会提供一个有更改的节点,如果目标父元素和子元素都位于这个节点下,那么没有问题,我们可以很轻松的搜索到。但如果 MutationObserver 回调的节点位于父元素和子元素之间,搜索就会变得略微复杂,因为我们需要通知向两个方向搜索。


最终妥协了上述两种情况,使用的解决方案是,使用 MutationObserver 监听这个 document.body 的变化。在每次变化发生之后,遍历整个文档寻找目标节点。

这样的做法好处是,遍历的逻辑非常简单,直接使用 JQuery 就可以做到。坏处是更加耗时,因为每次整个文档的任意一处发生变化时,哪怕变化的地方与我们的目标毫无关系,都需要遍历整个文档树,而且通常整个 HTML 文档会在短时间内频繁地更新。为了解决性能问题,使用 Lodash 的 debounce 函数对回调去抖动,这样短时间内的频繁更新只会触发一次搜索。

大致代码如下:

new MutationObserver((mutationList) = {
for (const mutation of mutationList){
// 只关心添加节点的变化
if (mutation.addedNodes.length) {
// 200 毫秒内的频繁调用只会触发一次
_.debounce(() => {
$('...').each((index, ele) => {
// 找到的目标
});
}, 200);
}
}
}).observe(document.body, {
childList: true,
subtree: true
});

3.3. 异步更新状态

在开发中还遇到一个小情景:现在所有的稍后再看按钮都成功替换成了自定义的按钮,然而因为稍后再看列表是异步获取的,没办法同步地给这些按钮设置是否勾选的状态。

那么如何异步地给按钮们更新状态呢?

一个最简单粗暴的思路就是:按钮默认都是不勾选的状态。此时去获取稍后再看列表,待数据返回后,再从页面中找回所有的按钮,依次给他们分配状态。

for (const oldButton of oldButtons) {
const button = document.createElement('div');
button.aid = '...';
// 默认不勾选
button.checked = false;
button.className = 'watch-later-plus-button';
oldButton.replaceWith(button);
}

const watchlaterList = await getWatchlaterList();
$('.watch-later-plus-button')
.filter('...') // 根据 watchlaterList 过滤出需要勾选的按钮
.each((_, ele) => ele.checked = true);

上面的想法可以通过保存按钮引用的方式减少一次遍历:

const newButtons = [];
for (const oldButton of oldButtons) {
const button = document.createElement('div');
button.aid = '...';
// 默认不勾选
button.checked = false;
newButtons.push(buttons);
oldButton.replaceWith(button);
}

const watchlaterList = await getWatchlaterList();
buttons.filter('...') // 根据 watchlaterList 过滤出需要勾选的按钮
.forEach((ele) => ele.checked = true);

上面两个方法本质是一样的,在创建按钮之后单独维护了一系列用于更新状态的语句。

我们可以利用闭包以及 Promise 的特性,写出这样的方法:

const watchLaterList = (() => {
let promise;
return () => {
if (!promise) {
promise = getWatchLaterList();
}
return promise;
};
})();

for (const oldButton of oldButtons) {
const button = document.createElement('div');
button.aid = '...';
watchLaterList().then(list => {
button.checked = list.includes(button.aid);
});
buttons.push(buttons);
oldButton.replaceWith(button);
}

这样更新状态的逻辑就不需要单独维护了。

4. 现状和吐槽

使用脚本之后,上面提到的 B站稍后再看的问题都得到了解决,特别是番剧也支持稍后再看之后,使用体验非常棒。

image-20190125224055885

不要嫌弃我的 UI,又不是不能用。之后 B 站再改 UI 或 API 的时候再更新吧。

吐槽一下 B 站的前端,写个新版本的 UI 还只有播放界面才有,还是常规视频的播放界面有新 UI 而番剧的播放界面没有;其他地方都是普通的多页应用,但是到了稍后再看和个人空间确实单页面应用;就一个稍后再看的按钮的逻辑,在首页、动态悬浮窗、动态首页、空间页面的实现居然都是不一样的。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK