Greasy Fork

Greasy Fork is available in English.

B站“稍后再看”按钮

B站新版顶栏中加回“稍后再看”的按钮,并将视频播放页中隐藏在弹出菜单中的“稍后再看”按钮移出来

目前为 2020-07-08 提交的版本,查看 最新版本

// ==UserScript==
// @id             BilibiliWatchlaterButton@Laster2800
// @name           B站“稍后再看”按钮
// @version        1.4
// @namespace      laster2800
// @author         Laster2800
// @description    B站新版顶栏中加回“稍后再看”的按钮,并将视频播放页中隐藏在弹出菜单中的“稍后再看”按钮移出来
// @grant          GM_xmlhttpRequest
// @connect        api.bilibili.com
// @include        *://www.bilibili.com/*
// @include        *://message.bilibili.com/*
// @include        *://search.bilibili.com/*
// @include        *://space.bilibili.com/*
// @include        *://t.bilibili.com/
// @include        *://t.bilibili.com/?spm_id_from=*
// @include        *://t.bilibili.com/?tab=*
// @exclude        *://message.bilibili.com/pages/*
// ==/UserScript==

(function() {
    // 新版顶栏中加入“稍后再看”的按钮
    executeAfterElementLoad({
        selector: '.user-con.signin',
        callback: addHeaderWatchlaterButton,
    })
    if (/bilibili.com\/video(|\/.*)$/.test(location.href)) {
        // 将视频播放页中隐藏在弹出菜单中的“稍后再看”按钮移出来
        executeAfterConditionPass({
            condition: () => {
                // 必须在确定 Vue 加载完成后再修改 DOM 结构,否则会导致 Vue 加载出错造成页面错误
                var app = document.querySelector('#app')
                var vueLoad = app && app.__vue__
                if (!vueLoad) {
                    return false
                }
                var atr = document.querySelector('#arc_toolbar_report')
                var original = atr && atr.querySelector('.van-watchlater')
                if (original && original.__vue__) {
                    return [atr, original]
                } else {
                    return false
                }
            },
            callback: addVideoWatchlaterButton,
        })
    }
})();

function addHeaderWatchlaterButton(header) {
    if (header) {
        var collect = header.children[4]
        var watchlater = header.children[6].cloneNode(true)
        var link = watchlater.firstChild
        link.href = 'https://www.bilibili.com/watchlater/#/list'
        var text = link.firstChild
        text.innerText = '稍后再看'
        header.insertBefore(watchlater, collect)

        // 鼠标移动到“稍后再看”按钮上时,以 Tooltip 形式显示“稍后再看”列表
        var watchlaterPanelSelector = '[role=tooltip][aria-hidden=false] .tabs-panel [title=稍后再看]'
        var dispVue = collect.firstChild.__vue__
        watchlater.onmouseover = () => {
            // 确保原列表完全消失后再显示,避免从“收藏”移动到“稍后再看”时列表反而消失的问题
            executeAfterConditionPass({
                condition: () => !document.querySelector(watchlaterPanelSelector),
                callback: () => {
                    dispVue.showPopper = true
                    executeAfterElementLoad({
                        selector: watchlaterPanelSelector,
                        callback: watchlaterPanel => watchlaterPanel.parentNode.click(),
                        interval: 50,
                        timeout: 1500,
                    })
                },
                interval: 10,
                timeout: 500,
            })
        }
        // 鼠标从“稍后再看”离开时关闭列表,但移动到“收藏”上面时不关闭
        collect.onmouseover = () => { collect.mouseOver = true }
        collect.onmouseleave = () => { collect.mouseOver = false }
        watchlater.onmouseleave = () => {
            // 要留出足够空间让 collect.mouseOver 变化
            // 但有时候还是会闪,毕竟常规方式估计是无法阻止鼠标移动到“收藏”上时的 Vue 事件
            setTimeout(() => {
                if (!collect.mouseOver) {
                    dispVue.showPopper = false
                }
            }, 100)
        }
    }
}

function addVideoWatchlaterButton([atr, original]) {
    var oVue = original.__vue__
    var btn = document.createElement('label')
    var cb = document.createElement('input')
    cb.type = 'checkbox'
    cb.style.verticalAlign = 'middle'
    cb.style.margin = '0 2px 2px 0'
    btn.appendChild(cb)
    var text = document.createElement('span')
    text.innerText = '稍后再看'
    btn.className = 'appeal-text'
    cb.onclick = () => { // 不要附加到 btn 上,否则点击时会执行两次
        oVue.handler()
        var checked = !oVue.added
        // 检测操作是否生效,失败时弹出提示
        executeAfterConditionPass({
            condition: () => checked === oVue.added,
            callback: () => { cb.checked = checked },
            interval: 50,
            timeout: 500,
            onTimeout: () => {
                cb.checked = oVue.added
                alert(checked ? '添加至稍后再看失败' : '从稍后再看移除失败')
            },
        })
    }
    btn.appendChild(text)
    atr.appendChild(btn)
    original.parentNode.style.display = 'none'

    // oVue.added 第一次取到的值总是 false,从页面无法获取到该视频是否已经在稍后再看列表中,需要使用API查询
    GM_xmlhttpRequest({
        method: 'GET',
        url: `https://api.bilibili.com/x/v2/history/toview/web?jsonp=jsonp`,
        onload: function(response) {
            if (response && response.responseText) {
                try {
                    var json = JSON.parse(response.responseText)
                    var watchList = json.data.list
                    var av = oVue.aid
                    for (var e of watchList) {
                        if (av == e.aid) {
                            oVue.added = true
                            cb.checked = true
                            break
                        }
                    }
                } catch(e) {
                    console.error(e)
                }
            }
        }
    })
}

/**
 * 在条件满足后执行操作
 *
 * 当条件满足后,如果不存在终止条件,那么直接执行 callback(result)。
 *
 * 当条件满足后,如果存在终止条件,且 stopTimeout 大于 0,则还会在接下来的 stopTimeout 时间内判断是否满足终止条件,称为终止条件的二次判断。
 * 如果在此期间,终止条件通过,则表示依然不满足条件,故执行 stopCallback() 而非 callback(result)。
 * 如果在此期间,终止条件一直失败,则顺利通过检测,执行 callback(result)。
 *
 * @param {Object} [options={}] 选项
 * @param {Function} [options.condition] 条件,当 condition() 返回的 result 为真值时满足条件
 * @param {Function} [options.callback] 当满足条件时执行 callback(result)
 * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
 * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
 * @param {Function} [options.onTimeout] 检测超时时执行 onTimeout()
 * @param {Function} [options.stopCondition] 终止条件,当 stopCondition() 返回的 stopResult 为真值时终止检测
 * @param {Function} [options.stopCallback] 终止条件达成时执行 stopCallback()(包括终止条件的二次判断达成)
 * @param {number} [options.stopInterval=50] 终止条件二次判断期间的检测时间间隔(单位:ms)
 * @param {number} [options.stopTimeout=0] 终止条件二次判断期间的检测超时时间(单位:ms)
 */
function executeAfterConditionPass(options) {
    var defaultOptions = {
        condition: () => true,
        callback: result => console.log(result),
        interval: 100,
        timeout: 5000,
        onTimeout: null,
        stopCondition: null,
        stopCallback: null,
        stopInterval: 50,
        stopTimeout: 0,
    }
    var o = { ...defaultOptions, ...options }
    if (!o.callback instanceof Function) {
        return
    }

    var cnt = 0
    var maxCnt = o.timeout / o.interval
    var tid = setInterval(() => {
        var result = o.condition()
        var stopResult = o.stopCondition && o.stopCondition()
        if (stopResult) {
            clearInterval(tid)
            o.stopCallback instanceof Function && o.stopCallback()
        } else if (++cnt > maxCnt) {
            clearInterval(tid)
            o.onTimeout instanceof Function && o.onTimeout()
        } else if (result) {
            clearInterval(tid)
            if (o.stopCondition && o.stopTimeout > 0) {
                executeAfterConditionPass({
                    condition: o.stopCondition,
                    callback: o.stopCallback,
                    interval: o.stopInterval,
                    timeout: o.stopTimeout,
                    onTimeout: () => o.callback(result)
                })
            } else {
                o.callback(result)
            }
        }
    }, o.interval)
}

/**
 * 在元素加载完成后执行操作
 *
 * 当元素加载成功后,如果没有设置终止元素选择器,那么直接执行 callback(element)。
 *
 * 当元素加载成功后,如果没有设置终止元素选择器,且 stopTimeout 大于 0,则还会在接下来的 stopTimeout 时间内判断终止元素是否加载成功,称为终止元素的二次加载。
 * 如果在此期间,终止元素加载成功,则表示依然不满足条件,故执行 stopCallback() 而非 callback(element)。
 * 如果在此期间,终止元素加载失败,则顺利通过检测,执行 callback(element)。
 *
 * @param {Object} [options={}] 选项
 * @param {Function} [options.selector] 该选择器指定要等待加载的元素 element
 * @param {Function} [options.callback] 当 element 加载成功时执行 callback(element)
 * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
 * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
 * @param {Function} [options.onTimeout] 检测超时时执行 onTimeout()
 * @param {Function} [options.stopCondition] 该选择器指定终止元素 stopElement,若该元素加载成功则终止检测
 * @param {Function} [options.stopCallback] 终止元素加载成功后执行 stopCallback()(包括终止元素的二次加载)
 * @param {number} [options.stopInterval=50] 终止元素二次加载期间的检测时间间隔(单位:ms)
 * @param {number} [options.stopTimeout=0] 终止元素二次加载期间的检测超时时间(单位:ms)
 */
function executeAfterElementLoad(options) {
    var defaultOptions = {
        selector: '',
        callback: el => console.log(el),
        interval: 100,
        timeout: 5000,
        onTimeout: null,
        stopSelector: null,
        stopCallback: null,
        stopInterval: 50,
        stopTimeout: 0,
    }
    var o = { ...defaultOptions, ...options }
    executeAfterConditionPass({
        ...o,
        condition: () => document.querySelector(o.selector),
        stopCondition: o.stopSelector && (() => document.querySelector(o.stopSelector)),
    })
}