Greasy Fork

Greasy Fork is available in English.

B站稍后再看功能增强

B站稍后再看功能增强,目前功能包括UI增强、稍后再看模式自动切换至普通模式播放(重定向)、稍后再看移除记录等,支持功能设置

当前为 2020-07-19 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @id              BilibiliWatchlaterPlus@Laster2800
// @name            B站稍后再看功能增强
// @version         2.10.0.20200719
// @namespace       laster2800
// @author          Laster2800
// @description     B站稍后再看功能增强,目前功能包括UI增强、稍后再看模式自动切换至普通模式播放(重定向)、稍后再看移除记录等,支持功能设置
// @homepage        http://greasyfork.icu/zh-CN/scripts/395456
// @supportURL      http://greasyfork.icu/zh-CN/scripts/395456/feedback
// @include         *://www.bilibili.com/*
// @include         *://message.bilibili.com/*
// @include         *://search.bilibili.com/*
// @include         *://space.bilibili.com/*
// @include         *://t.bilibili.com/*
// @include         *://account.bilibili.com/*
// @exclude         *://message.bilibili.com/pages/*
// @exclude         *://t.bilibili.com/h5/dynamic/specification
// @grant           GM_addStyle
// @grant           GM_xmlhttpRequest
// @grant           GM_registerMenuCommand
// @grant           GM_setValue
// @grant           GM_getValue
// @grant           GM_deleteValue
// @grant           GM_listValues
// @connect         api.bilibili.com
// @run-at          document-start
// ==/UserScript==

(function() {
  'use strict'
  // 全局对象
  var gm = {
    id: 'gm395456',
    configVersion: GM_getValue('configVersion'), // 配置版本,为执行初始化的代码版本对应的配置版本号
    configUpdate: 20200718, // 当前版本对应的配置版本号;若同一天修改多次,可以追加小数来区分
    config: {
      redirect: false,
    },
  }

  initAtDocumentStart()
  if (urlMatch(/bilibili.com\/medialist\/play\/watchlater(?=\/|$)/)) {
    if (gm.config.redirect) { // 重定向,document-start 就执行,尽可能快地将原页面掩盖过去
      fnRedirect()
      return // 必须 return,否则后面的内容还会执行使得加载速度超极慢
    }
  }

  // 脚本的其他部分推迟至 DOMContentLoaded 执行
  document.addEventListener('DOMContentLoaded', () => {
    init()
    updateVersion()
    readConfig()
    addScriptMenu()
    // 所有页面
    if (gm.config.headerButton) {
      fnHeaderButton()
    }
    if (urlMatch(gm.regex.page_watchlaterList)) {
      // 列表页面
      fnOpenListVideo()
      createWatchlaterListUI()
      if (gm.config.removeHistory) {
        saveWatchlaterListData()
      }
    } else if (urlMatch(gm.regex.page_videoNormalMode)) {
      // 播放页面(正常模式)
      if (gm.config.videoButton) {
        fnVideoButton_Normal()
      }
    } else if (urlMatch(gm.regex.page_videoWatchlaterMode)) {
      // 播放页面(稍后再看模式)
      if (gm.config.videoButton) {
        fnVideoButton_Watchlater()
      }
    }
    addStyle()

    /* END OF PROC, BEGIN OF FUNCTION */

    /**
     * 初始化
     */
    function init() {
      gm.url = {
        api_queryWatchlaterList: 'https://api.bilibili.com/x/v2/history/toview/web?jsonp=jsonp',
        api_addToWatchlater: 'https://api.bilibili.com/x/v2/history/toview/add',
        api_removeFromWatchlater: 'https://api.bilibili.com/x/v2/history/toview/del',
        page_watchlaterList: 'https://www.bilibili.com/watchlater/#/list',
        page_videoNormalMode: 'https://www.bilibili.com/video',
        page_videoWatchlaterMode: 'https://www.bilibili.com/medialist/play/watchlater',
        page_watchlaterPlayAll: 'https://www.bilibili.com/medialist/play/watchlater/p1',
        noop: 'javascript:void(0)',
      }

      gm.regex = {
        page_videoNormalMode: /bilibili.com\/video(|\/.*)$/,
        page_videoWatchlaterMode: /bilibili.com\/medialist\/play\/watchlater(?=\/|$)/,
        page_watchlaterList: /bilibili.com\/watchlater\/.*#.*\/list/,
      }

      gm.const = {
        // 移除记录保存相关
        rhsMin: 1,
        rhsMax: 1024, // 经过性能测试,放宽到 1024 应该没有太大问题
        rhsWarning: 256,
        // 渐变时间
        fadeTime: 400,
        textFadeTime: 100,
        // 信息框
        messageTime: 1200,
        messageTop: '70%',
        messageLeft: '50%',
      }

      gm.config = {
        ...gm.config,
        headerButton: true,
        openHeaderDropdownLink: 'ohdl_openInCurrent',
        headerButtonOpL: 'op_openListInCurrent',
        headerButtonOpR: 'op_openUserSetting',
        videoButton: true,
        openListVideo: 'olv_openInCurrent',
        removeHistory: true,
        removeHistorySaves: 64, // 就目前的PC运算力,即使达到 gm.const.rhsWarning 且在极限情况下也不会有明显的卡顿
        removeHistorySearchTimes: 8,
        removeHistoryData: null, // 特殊处理
        resetAfterFnUpdate: false,
        reloadAfterSetting: true,
      }

      gm.menu = {
        // key: { state, el, openHandler, closeHandler }
        setting: { state: false },
        history: { state: false },
      }

      gm.el = {
        gmRoot: document.body.appendChild(document.createElement('div')),
        setting: null,
        history: null,
      }
      gm.el.gmRoot.id = gm.id

      gm.error = {
        HTML_PARSING: `HTML解析错误,可能是B站网页改版。请联系脚本作者:${GM_info.script.supportURL}`,
        NETWORK: `网络连接错误,也可能是脚本内部数据出错,重置脚本数据也许能解决问题。无法解决请联系脚本作者:${GM_info.script.supportURL}`,
      }
    }

    /**
     * 版本更新处理
     */
    function updateVersion() {
      // 该项与更新相关,在此处处理
      gm.config.resetAfterFnUpdate = gmValidate('resetAfterFnUpdate', gm.config.resetAfterFnUpdate)

      if (gm.configVersion !== 0 && gm.configVersion !== gm.configUpdate) {
        if (gm.config.resetAfterFnUpdate) {
          gm.configVersion = 0
          return
        }

        if (gm.configVersion < gm.configUpdate) {
          if (gm.configVersion < 20200718) {
            // 2.8.0.20200718
            // 强制设置为新的默认值
            GM_setValue('removeHistorySaves', gm.config.removeHistorySaves)
            var removeHistory = GM_getValue('removeHistory')
            if (removeHistory) {
              // 修改容量
              var removeHistoryData = GM_getValue('removeHistoryData')
              if (removeHistoryData) {
                Object.setPrototypeOf(removeHistoryData, PushQueue.prototype)
                removeHistoryData.setCapacity(gm.const.rhsMax)
                GM_setValue('removeHistoryData', removeHistoryData)
              }
            } else {
              // 如果 removeHistory 关闭则移除 removeHistoryData
              GM_setValue('removeHistoryData', null)
            }
            // 升级配置版本
            gm.configVersion = gm.configUpdate
            GM_setValue('configVersion', gm.configVersion)
          }
        } else if (gm.configVersion === undefined) {
          if (GM_getValue('gm395456') > 0) {
            // 2.6.0.20200717 版本重构
            for (var name in gm.config) {
              var oldName = 'gm395456_' + name
              var value = GM_getValue(oldName)
              GM_setValue(name, value)
              GM_deleteValue(oldName)
            }
            gm.configVersion = GM_getValue('gm395456')
            GM_setValue('configVersion', gm.configVersion) // 保留配置版本
            GM_deleteValue('gm395456')
          }
        }
      }
    }

    /**
     * 用户配置读取
     */
    function readConfig() {
      var cfgDocumentStart = { redirect: true } // document-start 时期就处理过的配置
      var cfgManual = { removeHistoryData: true, resetAfterFnUpdate: true } // 手动处理的配置
      if (gm.configVersion > 0) {
        // 对配置进行校验
        var cfgNoWriteBack = { removeHistorySearchTimes: true } // 不进行回写的配置
        for (var name in gm.config) {
          if (!cfgDocumentStart[name] && !cfgManual[name]) {
            gm.config[name] = gmValidate(name, gm.config[name], !cfgNoWriteBack[name])
          }
        }
        // 特殊处理
        if (gm.config.removeHistorySearchTimes > gm.config.removeHistorySaves) {
          gm.config.removeHistorySearchTimes = gm.config.removeHistorySaves
          GM_setValue('removeHistorySearchTimes', gm.config.removeHistorySearchTimes)
        }
        // 处理 removeHistoryData
        if (gm.config.removeHistory) {
          gm.config.removeHistoryData = gmValidate('removeHistoryData', null, false)
          if (gm.config.removeHistoryData) {
            Object.setPrototypeOf(gm.config.removeHistoryData, PushQueue.prototype) // 还原类型信息
            if (gm.config.removeHistoryData.maxSize != gm.config.removeHistorySaves) {
              gm.config.removeHistoryData.setMaxSize(gm.config.removeHistorySaves)
            }
          } else {
            gm.config.removeHistoryData = new PushQueue(gm.config.removeHistorySaves, gm.const.rhsMax)
            GM_setValue('removeHistoryData', gm.config.removeHistoryData)
          }
        }
      } else {
        // 用户强制初始化,或者第一次安装脚本
        gm.configVersion = 0
        if (gm.config.removeHistory) {
          gm.config.removeHistoryData = new PushQueue(gm.config.removeHistorySaves, gm.const.rhsMax)
          GM_setValue('removeHistoryData', gm.config.removeHistoryData)
        }
        for (name in gm.config) {
          if (!cfgDocumentStart[name] && !cfgManual[name]) {
            GM_setValue(name, gm.config[name])
          }
        }
      }
    }

    /**
     * 添加脚本菜单
     */
    function addScriptMenu() {
      // 用户配置设置
      GM_registerMenuCommand('用户设置', openUserSetting)
      if (!gm.configVersion) { // 初始化
        openUserSetting(true)
      }
      // 稍后再看移除记录
      if (gm.config.removeHistory) {
        GM_registerMenuCommand('显示稍后再看移除记录', openRemoveHistory)
      }
      // 强制初始化
      GM_registerMenuCommand('重置脚本数据', resetScript)
    }

    /**
     * 顶栏中加入稍后再看入口
     */
    function fnHeaderButton() {
      executeAfterElementLoad({
        selector: '.user-con.signin',
        callback: header => {
          if (header) {
            var collect = header.children[4]
            var watchlater = document.createElement('div')
            watchlater.className = 'item'
            var link = watchlater.appendChild(document.createElement('a'))
            var text = link.appendChild(document.createElement('span'))
            text.className = 'name'
            text.innerText = '稍后再看'
            header.insertBefore(watchlater, collect)

            executeLeftClick(link)
            executeRightClick(watchlater)
            executeTooltip({ collect, watchlater })
          }
        },
      })

      /**
       * 处理鼠标左键点击
       */
      var executeLeftClick = link => {
        // 使用 href 和 target 的方式设置,保留浏览器中键强制新标签页打开的特性
        var left = getHrefAndTarget(gm.config.headerButtonOpL)
        link.href = left.href
        link.target = left.target
        switch (gm.config.headerButtonOpL) {
          case 'op_openUserSetting':
            link.onclick = () => openUserSetting()
            break
          case 'op_openRemoveHistory':
            link.onclick = () => openRemoveHistory()
            break
        }
      }

      /**
       * 处理鼠标右键点击
       */
      var executeRightClick = watchlater => {
        watchlater.oncontextmenu = function(e) {
          if (gm.config.headerButtonOpR != 'op_noOperation') {
            e && e.preventDefault && e.preventDefault()
          }
          switch (gm.config.headerButtonOpR) {
            case 'op_openListInCurrent':
            case 'op_openListInNew':
            case 'op_playAllInCurrent':
            case 'op_playAllInNew':
              var right = getHrefAndTarget(gm.config.headerButtonOpR)
              window.open(right.href, right.target)
              break
            case 'op_openUserSetting':
              openUserSetting()
              break
            case 'op_openRemoveHistory':
              openRemoveHistory()
              break
          }
        }
      }

      /**
       * 处理弹出菜单
       */
      function executeTooltip({ collect, watchlater }) {
        // 鼠标移动到稍后再看入口上时,以 Tooltip 形式显示稍后再看列表
        var dropdownSelector = open => { // 注意,该 selector 无法直接选出对应的下拉菜单,只能用作拼接
          if (typeof open == 'boolean') {
            return `[role=tooltip][aria-hidden=${!open}]`
          } else {
            return '[role=tooltip][aria-hidden]'
          }
        }
        var tabsPanelSelector = open => `${dropdownSelector(open)} .tabs-panel`
        var videoPanel = open => `${dropdownSelector(open)} .favorite-video-panel`

        var defaultCollectPanelChildSelector = open => `${tabsPanelSelector(open)} [title=默认收藏夹]`
        var watchlaterPanelChildSelector = open => `${tabsPanelSelector(open)} [title=稍后再看]`
        var activePanelSelector = open => `${tabsPanelSelector(open)} .tab-item--active`
        var dispVue = collect.firstChild.__vue__

        // addEventListener 尽量避免冲掉事件
        setTimeout(() => {
          watchlater.addEventListener('mouseenter', onEnterWatchlater)
          watchlater.addEventListener('mouseleave', onLeaveWatchlater)
          collect.addEventListener('mouseenter', onEnterCollect)
          collect.addEventListener('mouseleave', onLeaveCollect)
        })

        /**
         * 进入稍后再看入口的处理
         * 
         * @async
         */
        var onEnterWatchlater = async function() {
          try {
            var activePanel = document.querySelector(activePanelSelector(true))
            if (activePanel) {
              // 在没有打开下拉菜单前,获取不到 activePanel
              collect._activeTitle = activePanel.firstChild.title
              collect._activePanel = activePanel
            }

            // 不能直接修改 showPopper,可能现在鼠标刚离开“收藏”,Vue 在等待菜单关闭之后将其改为 false
            // 如果直接改 showPopper = true,等下会被 Vue 又改回 false,先等 Vue 改好再说
            await waitForConditionPass({
              condition: () => !dispVue.showPopper,
              interval: 10,
              timeout: 500,
            })
            // 不需要菜单真正关闭,只需该状态已经变为 false 就可以开始操作了,此时 DOM 上的菜单往往并没有真正关闭
            dispVue.showPopper = true
            // 等待下拉菜单的状态变为“打开”再操作,会比较安全,虽然此时 DOM 上的菜单可能没有真正打开
            var watchlaterPanelChild = await waitForElementLoad({
              selector: watchlaterPanelChildSelector(true),
              interval: 10,
              timeout: 500,
            })
            watchlaterPanelChild.parentNode.click()
          } catch (e) {
            console.error(gm.error.HTML_PARSING)
            console.error(e)
          }
          // 到这里才添加,避免掉前面 click 的影响,保持一致性
          addTabsPanelClickEvent()
        }

        /**
         * 离开稍后再看入口的处理
         */
        var onLeaveWatchlater = function() {
          // 要留出足够空间让 collect.mouseOver 变化
          // 但有时候还是会闪,毕竟常规方式估计是无法阻止鼠标移动到“收藏”上时的 Vue 事件
          setTimeout(() => {
            if (!collect.mouseOver) {
              dispVue.showPopper = false
            }
          }, 100)
        }

        /**
         * 进入“收藏”的处理
         * 
         * @async
         */
        var onEnterCollect = async function() {
          this.mouseOver = true
          try {
            var activePanel = await waitForElementLoad({
              selector: activePanelSelector(true),
              interval: 50,
              timeout: 1500,
            })
            var activeTitle = activePanel.firstChild.title
            if (activeTitle == '稍后再看') {
              if (!collect._activePanel || collect._activeTitle == '稍后再看') {
                // 一般来说,只有当打开页面后直接通过稍后再看入口打开下拉菜单,然后再将鼠标移动到“收藏”上,才会执行进来
                var defaultCollectPanelChild = await waitForElementLoad({
                  selector: defaultCollectPanelChildSelector(true),
                  interval: 50,
                  timeout: 1500,
                })
                collect._activeTitle = defaultCollectPanelChild.title
                collect._activePanel = defaultCollectPanelChild.parentNode
              }
              collect._activePanel.click()
            }
          } catch (e) {
            console.error(gm.error.HTML_PARSING)
            console.error(e)
          }
          addTabsPanelClickEvent()
        }

        /**
         * 离开“收藏”的处理
         */
        var onLeaveCollect = function() {
          this.mouseOver = false
        }

        /**
         * 给 tabsPanel 中每个收藏夹和稍后再看添加点击事件
         */
        var addTabsPanelClickEvent = () => {
          if (!collect._addTabsPanelClickEvent && gm.config.openHeaderDropdownLink == 'ohdl_openInCurrent') {
            setVideoPanelLinkTarget() // 先执行一次,让当前 videoPanel 中的 target 改变
            executeAfterElementLoad({
              selector: tabsPanelSelector(),
              callback: tabsPanel => {
                for (var child of tabsPanel.children) {
                  child.addEventListener('click', () => setVideoPanelLinkTarget())
                }
                collect._addTabsPanelClickEvent = true
              }
            })
          }
        }

        /**
         * 设置下拉菜单面板中视频链接的 target
         */
        var setVideoPanelLinkTarget = () => {
          setTimeout(() => {
            // var target = gm.config.openHeaderDropdownLink == 'ohdl_openInNew' ? '_blank' : '_self'
            var target = '_self'
            var links = document.querySelectorAll(`${videoPanel()} a`)
            for (var link of links) {
              link.target = target
            }
          }, 200)
        }
      }

      function getHrefAndTarget(op) {
        var href = ''
        if (/openList/i.test(op)) {
          href = gm.url.page_watchlaterList
        } else if (/playAll/.test(op)) {
          href = gm.url.page_watchlaterPlayAll
        } else {
          href = gm.url.noop
        }
        var target = ''
        if (/inCurrent/i.test(op)) {
          target = '_self'
        } else if (/inNew/i.test(op)) {
          target = '_blank'
        } else {
          target = '_self'
        }
        return { href, target }
      }
    }

    /**
     * 常规播放页加入快速切换稍后再看状态的按钮
     */
    function fnVideoButton_Normal() {
      /**
       * 继续执行的条件
       */
      var executeCondition = () => {
        // 必须在确定 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
        }
      }

      executeAfterConditionPass({
        condition: executeCondition,
        callback: ([atr, original]) => {
          var oVue = original.__vue__
          var btn = document.createElement('label')
          btn.id = `${gm.id}-normal-video-btn`
          var cb = document.createElement('input')
          cb.type = 'checkbox'
          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
                message(checked ? '添加至稍后再看失败' : '从稍后再看移除失败')
              },
            })
          }
          btn.appendChild(text)
          atr.appendChild(btn)
          original.parentNode.style.display = 'none'
          setButtonStatus(oVue, cb)
        },
      })

      /**
       * 设置按钮的稍后再看状态
       * 
       * @async
       */
      var setButtonStatus = async (oVue, cb) => {
        var aid = oVue.aid // also unsafeWindow.aid
        var status = await getVideoWatchlaterStatusByAid(aid)
        oVue.added = status
        cb.checked = status
      }
    }

    /**
     * 稍后再看播放页加入快速切换稍后再看状态的按钮
     */
    function fnVideoButton_Watchlater() {
      var aidMap = new Map()

      /**
       * 继续执行的条件
       */
      var executeCondition = () => {
        // 必须在确定 Vue 加载完成后再修改 DOM 结构,否则会导致 Vue 加载出错造成页面错误
        var app = document.querySelector('#app')
        var vueLoad = app && app.__vue__
        if (!vueLoad) {
          return false
        }
        return app.querySelector('#playContainer .left-container .play-options .play-options-more')
      }

      executeAfterConditionPass({
        condition: executeCondition,
        callback: more => {
          var btn = document.createElement('label')
          btn.id = `${gm.id}-watchlater-video-btn`
          btn.onclick = e => e.stopPropagation()
          var cb = document.createElement('input')
          cb.type = 'checkbox'
          btn.appendChild(cb)
          var text = document.createElement('span')
          text.innerText = '稍后再看'
          btn.appendChild(text)
          more.appendChild(btn)

          btn.added = true
          cb.checked = true // 第一次打开时,默认在稍后再看中
          var csrf = getCsrf()
          cb.onclick = () => executeSwitch({ btn, cb, csrf })

          // 切换视频时的处理
          createLocationchangeEvent()
          window.addEventListener('locationchange', async function() {
            if (!btn.aid) {
              btn.aid = await getAid()
            }
            executeAfterConditionPass({
              condition: () => {
                var aid = unsafeWindow.aid // 切换之后必然会有 unsafeWindow.aid
                if (aid && aid != btn.aid) {
                  return aid
                }
              },
              callback: async aid => {
                btn.aid = aid
                var status = await getVideoWatchlaterStatusByAid(btn.aid)
                btn.added = status
                cb.checked = status
              }
            })
          })
        },
      })

      /**
       * 处理视频状态的切换
       * 
       * @async
       */
      var executeSwitch = async ({ btn, cb, csrf }) => { // 不要附加到 btn 上,否则点击时会执行两次
        btn.aid = await getAid()
        if (!btn.aid) {
          cb.checked = btn.added
          message('网络错误,操作失败')
          return
        }
        var data = new FormData()
        data.append('aid', btn.aid)
        data.append('csrf', csrf)
        GM_xmlhttpRequest({
          method: 'POST',
          url: btn.added ? gm.url.api_removeFromWatchlater : gm.url.api_addToWatchlater,
          data: data,
          onload: function(response) {
            try {
              var note = btn.added ? '从稍后再看移除' : '添加到稍后再看'
              if (JSON.parse(response.response).code == 0) {
                btn.added = !btn.added
                cb.checked = btn.added
                message(note + '成功')
              } else {
                cb.checked = btn.added
                message(`网络错误,${note}失败`)
              }
            } catch (e) {
              console.error(gm.error.NETWORK)
              console.error(e)
            }
          }
        })
      }

      /**
       * 获取 CSRF
       */
      var getCsrf = () => {
        var cookies = document.cookie.split('; ')
        cookies = cookies.reduce((prev, val) => {
          var parts = val.split('=')
          var key = parts[0]
          var value = parts[1]
          prev[key] = value
          return prev
        }, {})
        var csrf = cookies.bili_jct
        return csrf
      }

      /**
       * 获取当前页面对应的 aid
       */
      var getAid = async () => {
        var aid = unsafeWindow.aid // 第一次打开播放页时不存在,但切换视频后就存在了
        if (aid) {
          return aid
        }

        var bvid = await getBvid()
        aid = aidMap.get(bvid)
        if (aid) {
          return aid
        }

        // 用笨方法查算了,那套算法太烦,不想弄过来
        // 这里不能根据分P来推测 aid,因为该功能的引入,分P不一定对得上真正的列表
        return new Promise(resolve => {
          GM_xmlhttpRequest({
            method: 'GET',
            url: gm.url.api_queryWatchlaterList,
            onload: function(response) {
              try {
                var json = JSON.parse(response.responseText)
                var watchlaterList = json.data.list
                var aid = null
                for (var e of watchlaterList) {
                  if (bvid == e.bvid) {
                    aid = e.aid
                    break
                  }
                }
                if (aid) {
                  aidMap.set(bvid, aid)
                }
                resolve(aid)
              } catch (e) {
                console.error(gm.error.NETWORK)
                console.error(e)
              }
            },
          })
        })
      }

      /**
       * 获取当前页面的 bvid
       * 
       * @async
       */
      var getBvid = async () => {
        return new Promise(resolve => {
          executeAfterConditionPass({
            condition: () => {
              try {
                var url = document.querySelector('.play-title-location').href
                var m = url.match(/(?<=\/)BV[a-zA-Z\d]+(?=\/|$)/)
                if (m && m[0]) {
                  return m[0]
                }
              } catch (e) {
                // ignore
              }
            },
            callback: bvid => resolve(bvid)
          })
        })
      }

      /**
       * 创建 locationchange 事件
       * @see @{link https://stackoverflow.com/a/52809105}
       */
      var createLocationchangeEvent = () => {
        if (!unsafeWindow._createLocationchangeEvent) {
          history.pushState = (f => function pushState() {
            var ret = f.apply(this, arguments)
            window.dispatchEvent(new Event('pushstate'))
            window.dispatchEvent(new Event('locationchange'))
            return ret
          })(history.pushState)
          history.replaceState = (f => function replaceState() {
            var ret = f.apply(this, arguments)
            window.dispatchEvent(new Event('replacestate'))
            window.dispatchEvent(new Event('locationchange'))
            return ret
          })(history.replaceState)
          window.addEventListener('popstate', () => {
            window.dispatchEvent(new Event('locationchange'))
          })
          unsafeWindow._createLocationchangeEvent = true
        }
      }
    }

    /**
     * 根据 aid 获取视频的稍后再看状态
     * @async
     * @param {number} aid AV号
     * @returns {Promise<boolean>} 视频是否在稍后再看中
     */
    async function getVideoWatchlaterStatusByAid(aid) {
      // oVue.added 第一次取到的值总是 false,从页面无法获取到该视频是否已经在稍后再看列表中,需要使用API查询
      return new Promise(resolve => {
        GM_xmlhttpRequest({
          method: 'GET',
          url: gm.url.api_queryWatchlaterList,
          onload: function(response) {
            if (response && response.responseText) {
              try {
                var json = JSON.parse(response.responseText)
                var watchlaterList = json.data.list
                for (var e of watchlaterList) {
                  if (aid == e.aid) {
                    resolve(true)
                    return
                  }
                }
                resolve(false)
              } catch (e) {
                console.error(gm.error.NETWORK)
                console.error(e)
              }
            }
          }
        })
      })
    }

    /**
     * 处理列表页面点击视频时的行为
     */
    function fnOpenListVideo() {
      if (gm.config.openListVideo == 'olv_openInNew') {
        // 如果列表页面在新标签页打开视频
        var base = document.head.appendChild(document.createElement('base'))
        base.id = 'gm-base'
        base.target = '_blank'
      }
    }

    /**
     * 保存列表页面数据,用于生成移除记录
     */
    function saveWatchlaterListData() {
      GM_xmlhttpRequest({
        method: 'GET',
        url: gm.url.api_queryWatchlaterList,
        onload: function(response) {
          if (response && response.responseText) {
            var current = []
            try {
              var json = JSON.parse(response.responseText)
              var watchlaterList = json.data.list
              for (var e of watchlaterList) {
                current.push({
                  title: e.title,
                  bvid: e.bvid,
                })
              }
              gm.config.removeHistoryData.push(current)
              GM_setValue('removeHistoryData', gm.config.removeHistoryData)
            } catch (e) {
              console.error(gm.error.NETWORK)
              console.error(e)
            }
          }
        }
      })
    }

    /**
     * 生成列表页面的 UI
     */
    function createWatchlaterListUI() {
      var r_con = document.querySelector('.watch-later-list.bili-wrapper header .r-con')
      if (gm.config.removeHistory) {
        // 在列表页面加入“移除记录”
        var removeHistoryButton = r_con.appendChild(document.createElement('div'))
        removeHistoryButton.innerText = '移除记录'
        removeHistoryButton.className = 's-btn'
        removeHistoryButton.onclick = () => openRemoveHistory() // 要避免 MouseEvent 的传递
      }
      // 在列表页面加如“增强设置”
      var plusButton = r_con.appendChild(document.createElement('div'))
      plusButton.innerText = '增强设置'
      plusButton.className = 's-btn'
      plusButton.onclick = () => openUserSetting() // 要避免 MouseEvent 的传递
    }

    /**
     * 打开用户设置
     * @param {boolean} initial 是否进行初始化设置
     */
    function openUserSetting(initial) {
      if (gm.el.setting) {
        openMenuItem('setting')
      } else {
        var el = {}
        var configMap = {
          // { attr, manual, needNotReload }
          headerButton: { attr: 'checked' },
          openHeaderDropdownLink: { attr: 'value' },
          headerButtonOpL: { attr: 'value' },
          headerButtonOpR: { attr: 'value' },
          videoButton: { attr: 'checked' },
          redirect: { attr: 'checked' },
          openListVideo: { attr: 'value' },
          removeHistory: { attr: 'checked', manual: true },
          removeHistorySaves: { attr: 'value', manual: true, needNotReload: true },
          removeHistorySearchTimes: { attr: 'value', manual: true, needNotReload: true },
          resetAfterFnUpdate: { attr: 'checked' },
          reloadAfterSetting: { attr: 'checked', needNotReload: true },
        }
        setTimeout(() => {
          initSetting()
          handleConfigItem()
          handleSettingItem()
          openMenuItem('setting')
        })

        /**
         * 设置页面初始化
         */
        var initSetting = () => {
          gm.el.setting = gm.el.gmRoot.appendChild(document.createElement('div'))
          gm.menu.setting.el = gm.el.setting
          gm.el.setting.className = 'gm-setting'
          gm.el.setting.innerHTML = `
<div class="gm-setting-page">
    <div class="gm-title">
        <div id="gm-maintitle" onclick="window.open('${GM_info.script.homepage}')" title="${GM_info.script.homepage}">B站稍后再看功能增强</div>
        <div class="gm-subtitle">V${GM_info.script.version} by ${GM_info.script.author}</div>
    </div>
    <div class="gm-items">
        <div class="gm-item">
            <label title="在顶栏“动态”和“收藏”之间加入稍后再看入口,鼠标移至上方时弹出列表菜单,支持点击功能设置。">
                <span>【所有页面】在顶栏中加入稍后再看入口</span><input id="gm-headerButton" type="checkbox"></label>
            <div class="gm-subitem" title="选择在下拉菜单中点击视频的行为。为了保证行为一致,这个选项也会影响下拉菜单中收藏夹视频的打开。">
                <span>在下拉菜单中点击视频时</span>
                <select id="gm-openHeaderDropdownLink">
                    <option value="ohdl_openInCurrent">在当前页面打开</option>
                    <option value="ohdl_openInNew">在新标签页打开</option>
                </select>
            </div>
            <div class="gm-subitem" title="选择左键点击入口时执行的操作。">
                <span>在入口上点击鼠标左键时</span>
                <select id="gm-headerButtonOpL"></select>
            </div>
            <div class="gm-subitem" title="选择右键点击入口时执行的操作。">
                <span>在入口上点击鼠标右键时</span>
                <select id="gm-headerButtonOpR"></select>
            </div>
        </div>
        <label class="gm-item" title="在播放页面(包括普通模式和稍后再看模式)中加入能将视频快速切换添加或移除出稍后再看列表的按钮。">
            <span>【播放页面】加入快速切换视频稍后再看状态的按钮</span><input id="gm-videoButton" type="checkbox"></label>
        <label class="gm-item" title="打开【${gm.url.page_videoWatchlaterMode}】页面时,自动切换至【${gm.url.page_videoNormalMode}】页面进行播放。">
            <span>【播放页面】从稍后再看模式切换到普通模式播放</span><input id="gm-redirect" type="checkbox"></label>
        <label class="gm-item" title="设置在【${gm.url.page_watchlaterList}】页面点击视频时的行为。">
            <span>【列表页面】点击视频时</span>
            <select id="gm-openListVideo">
                <option value="olv_openInCurrent">在当前页面打开</option>
                <option value="olv_openInNew">在新标签页打开</option>
            </select>
        </label>
        <div class="gm-item">
            <label title="保留最近几次打开【${gm.url.page_watchlaterList}】页面时稍后再看列表的记录,以查找出这段时间内将哪些视频移除出稍后再看,用于防止误删操作。关闭该选项后,会将内部历史数据清除!">
                <span>【列表页面】开启稍后再看移除记录(防误删)</span>
                <input id="gm-removeHistory" type="checkbox">
                <span id="gm-rhWarning" class="gm-warning">⚠</span>
            </label>
            <div class="gm-subitem" title="较大的数值可能会带来较大的开销,经过性能测试,作者认为在设置在${gm.const.rhsWarning}以下时,即使在极限情况下也不会产生让人能察觉到的卡顿(存取总时不超过100ms),但在没有特殊要求的情况下依然不建议设置到这么大。该项修改后,会立即对过期记录进行清理,重新修改为原来的值无法还原被清除的记录,设置为比原来小的值需慎重!(范围:${gm.const.rhsMin} ~ ${gm.const.rhsMax})">
                <span>保存最近多少次列表页面数据用于生成移除记录</span>
                <input id="gm-removeHistorySaves" type="text">
                <span id="gm-rhsWarning" class="gm-warning">⚠</span>
            </div>
            <div class="gm-subitem" title="搜寻时在最近多少次列表页面数据中查找,设置较小的值能较好地定位最近移除的视频。设置较大的值几乎不会对性能造成影响,但不能大于最近列表页面数据保存次数。">
                <span>默认历史回溯深度</span><input id="gm-removeHistorySearchTimes" type="text"></div>
        </div>
        <label class="gm-item" title="功能性更新后,是否强制进行初始化设置?特别地,该选项的设置在初始化设置时将被保留,但重置脚本数据时依然会被重置。">
            <span>【用户设置】功能性更新后进行初始化设置</span><input id="gm-resetAfterFnUpdate" type="checkbox"></label>
        <label class="gm-item" title="勾选后,如果更改的配置需要重新加载才能生效,那么会在设置完成后重新加载页面。">
            <span>【用户设置】必要时在设置完成后重新加载页面</span><input id="gm-reloadAfterSetting" type="checkbox"></label>
    </div>
    <div class="gm-bottom">
        <button id="gm-save">保存</button><button id="gm-cancel">取消</button>
    </div>
    <div id="gm-reset" title="重置脚本设置及内部数据,也许能解决脚本运行错误的问题。无法解决请联系脚本作者:${GM_info.script.supportURL}">重置脚本数据</div>
</div>
<div class="gm-shadow"></div>
`

          // 找出配置对应的元素
          for (var name in gm.config) {
            el[name] = gm.el.setting.querySelector('#gm-' + name)
          }

          el.save = gm.el.setting.querySelector('#gm-save')
          el.cancel = gm.el.setting.querySelector('#gm-cancel')
          el.shadow = gm.el.setting.querySelector('.gm-shadow')
          el.reset = gm.el.setting.querySelector('#gm-reset')
          el.reset.onclick = resetScript

          el.rhWarning = gm.el.setting.querySelector('#gm-rhWarning')
          initWarning(el.rhWarning, '关闭移除记录,或将列表页面数据保存次数设置为比原来小的值,都会造成对内部过期历史数据的清理!')
          el.rhsWarning = gm.el.setting.querySelector('#gm-rhsWarning')
          initWarning(el.rhsWarning, `该项设置过大时,在极端情况下可能会造成明显的卡顿,一般不建议该项超过${gm.const.rhsWarning}。当然,如果对机器的读写性能自信,可以无视该警告。`)

          el.headerButtonOpL.innerHTML = el.headerButtonOpR.innerHTML = `
<option value="op_openListInCurrent">在当前页面打开列表页面</option>
<option value="op_openListInNew">在新标签页打开列表页面</option>
<option value="op_playAllInCurrent">在当前页面播放全部</option>
<option value="op_playAllInNew">在新标签页播放全部</option>
<option value="op_openUserSetting">打开用户设置</option>
<option value="op_openRemoveHistory">打开稍后再看移除记录</option>
<option value="op_noOperation">不执行操作</option>
        `
        }

        /**
         * 维护与设置项相关的数据和元素
         */
        var handleConfigItem = () => {
          // 子项与父项相关联
          var subitemChange = (item, subs) => {
            for (var el of subs) {
              var parent = el.parentNode
              if (item.checked) {
                parent.removeAttribute('disabled')
              } else {
                parent.setAttribute('disabled', 'disabled')
              }
              el.disabled = !item.checked
            }
          }
          el.headerButton.onchange = function() {
            subitemChange(this, [el.openHeaderDropdownLink, el.headerButtonOpL, el.headerButtonOpR])
          }
          el.removeHistory.onchange = function() {
            subitemChange(this, [el.removeHistorySaves, el.removeHistorySearchTimes])
            setRhWaring()
          }

          // 输入框内容处理
          el.removeHistorySaves.oninput = function() {
            var v0 = this.value.replace(/[^\d]/g, '')
            if (v0 === '') {
              this.value = ''
            } else {
              var value = parseInt(v0)
              if (value > gm.const.rhsMax) {
                value = gm.const.rhsMax
              } else if (value < gm.const.rhsMin) {
                value = gm.const.rhsMin
              }
              this.value = value
            }
            setRhWaring()
            setRhsWarning()
          }
          el.removeHistorySaves.onblur = function() {
            if (this.value === '') {
              this.value = el.removeHistorySearchTimes.value
            }
            if (parseInt(el.removeHistorySearchTimes.value) > parseInt(this.value)) {
              el.removeHistorySearchTimes.value = this.value
            }
            setRhWaring()
            setRhsWarning()
          }
          el.removeHistorySearchTimes.oninput = function() {
            var v0 = this.value.replace(/[^\d]/g, '')
            if (v0 === '') {
              this.value = ''
            } else {
              var value = parseInt(v0)
              if (value > gm.const.rhsMax) {
                value = gm.const.rhsMax
              } else if (value < gm.const.rhsMin) {
                value = gm.const.rhsMin
              }
              this.value = value
            }
          }
          el.removeHistorySearchTimes.onblur = function() {
            if (this.value === '') {
              this.value = el.removeHistorySaves.value
            } else if (parseInt(el.removeHistorySaves.value) < parseInt(this.value)) {
              el.removeHistorySaves.value = this.value
              setRhWaring()
              setRhsWarning()
            }
          }
        }

        /**
         * 处理与设置页面相关的数据和元素
         */
        var handleSettingItem = () => {
          el.save.onclick = onSave
          gm.menu.setting.openHandler = onOpen
          el.cancel.onclick = () => closeMenuItem('setting')
          el.shadow.onclick = function() {
            if (!this.getAttribute('disabled')) {
              closeMenuItem('setting')
            }
          }
          if (initial) {
            el.reset.style.display = 'none'
            el.cancel.disabled = true
            el.shadow.setAttribute('disabled', 'disabled')
          }
        }

        var needReload = false
        /**
         * 设置保存时执行
         */
        var onSave = () => {
          // 通用处理
          for (var name in configMap) {
            var cfg = configMap[name]
            if (!cfg.manual) {
              var change = saveConfig(name, cfg.attr)
              if (!cfg.needNotReload) {
                needReload = needReload || change
              }
            }
          }

          // 特殊处理
          var resetMaxSize = false
          // removeHistory
          if (gm.config.removeHistory != el.removeHistory.checked) {
            gm.config.removeHistory = el.removeHistory.checked
            GM_setValue('removeHistory', gm.config.removeHistory)
            resetMaxSize = true
            needReload = true
          }
          // “因”中无 removeHistory,就说明 needReload 需要设置为 true,除非“果”不需要刷新页面就能生效
          if (gm.config.removeHistory) {
            var rhsV = parseInt(el.removeHistorySaves.value)
            if (rhsV != gm.config.removeHistorySaves && !isNaN(rhsV)) {
              // 因:removeHistorySaves
              // 果:removeHistorySaves & removeHistoryData
              if (gm.config.removeHistoryData) {
                gm.config.removeHistoryData.setMaxSize(rhsV)
              } else {
                gm.config.removeHistoryData = new PushQueue(rhsV, gm.const.rhsMax)
              }
              gm.config.removeHistorySaves = rhsV
              GM_setValue('removeHistorySaves', gm.config.removeHistorySaves)
              GM_setValue('removeHistoryData', gm.config.removeHistoryData)
              // 不需要修改 needReload
            } else if (resetMaxSize) {
              // 因:removeHistory
              // 果:removeHistoryData
              if (gm.config.removeHistoryData) {
                gm.config.removeHistoryData.setMaxSize(rhsV)
              } else {
                gm.config.removeHistoryData = new PushQueue(rhsV, gm.const.rhsMax)
              }
              GM_setValue('removeHistoryData', gm.config.removeHistoryData)
            }
            // 因:removeHistorySearchTimes
            // 果:removeHistorySearchTimes
            var rhstV = parseInt(el.removeHistorySearchTimes.value)
            if (rhstV != gm.config.removeHistorySearchTimes && !isNaN(rhstV)) {
              gm.config.removeHistorySearchTimes = rhstV
              GM_setValue('removeHistorySearchTimes', gm.config.removeHistorySearchTimes)
              // 不需要修改 needReload
            }
          } else if (resetMaxSize) {
            // 因:removeHistory
            // 果:removeHistoryData
            if (gm.config.removeHistoryData) {
              gm.config.removeHistoryData = null
              GM_setValue('removeHistoryData', gm.config.removeHistoryData)
            }
          }

          closeMenuItem('setting')
          if (initial) {
            // 更新配置版本
            gm.configVersion = gm.configUpdate
            GM_setValue('configVersion', gm.configVersion)
            // 关闭初始化状态
            setTimeout(() => {
              el.reset.style.display = 'unset'
              el.cancel.disabled = false
              el.shadow.removeAttribute('disabled')
            }, gm.const.fadeTime)
          }

          if (gm.config.reloadAfterSetting && needReload) {
            needReload = false
            location.reload()
          }
        }

        /**
         * 设置打开时执行
         */
        var onOpen = () => {
          for (var name in configMap) {
            var attr = configMap[name].attr
            el[name][attr] = gm.config[name]
          }
          el.headerButton.onchange()
          el.removeHistory.onchange()
        }

        /**
         * 保存配置
         * @param {string} name 配置名称
         * @param {string} attr 从对应元素的什么属性读取
         * @returns {boolean} 是否有实际更新
         */
        var saveConfig = (name, attr) => {
          var elValue = el[name][attr]
          if (gm.config[name] != elValue) {
            gm.config[name] = elValue
            GM_setValue(name, gm.config[name])
            return true
          }
          return false
        }

        /**
         * 设置 removeHistory 警告项
         */
        var setRhWaring = () => {
          var warn = false
          var rh = el.removeHistory.checked
          if (!rh) {
            warn = true
          } else {
            var rhs = parseInt(el.removeHistorySaves.value)
            if (isNaN(rhs)) {
              rhs = 0
            }
            if (rhs < gm.config.removeHistorySaves) {
              warn = true
            }
          }

          if (el.rhWarning.show) {
            if (!warn) {
              fade(false, el.rhWarning)
              el.rhWarning.show = false
            }
          } else {
            if (warn) {
              fade(true, el.rhWarning)
              el.rhWarning.show = true
            }
          }
        }

        /**
         * 设置 removeHistorySaves 警告项
         */
        var setRhsWarning = () => {
          var value = parseInt(el.removeHistorySaves.value)
          if (isNaN(value)) {
            value = 0
          }
          if (el.rhsWarning.show) {
            if (value <= gm.const.rhsWarning) {
              fade(false, el.rhsWarning)
              el.rhsWarning.show = false
            }
          } else {
            if (value > gm.const.rhsWarning) {
              fade(true, el.rhsWarning)
              el.rhsWarning.show = true
            }
          }
        }
      }

      /**
       * 设置警告项
       * @param {HTMLElement} elWarning 警告元素
       * @param {string} msg 警告信息
       */
      var initWarning = (elWarning, msg) => {
        elWarning.show = false
        elWarning.onmouseover = function() {
          var htmlMsg = `
<table><tr>
    <td style="font-size:1.8em;line-height:1.8em;padding-right:0.6em;">⚠</td>
    <td>${msg}</td>
</tr></table>
`
          this.msgbox = message(htmlMsg, { html: true, autoClose: false })
        }
        elWarning.onmouseleave = function() {
          if (this.msgbox) {
            closeMessage(this.msgbox)
          }
        }
      }
    }

    /**
     * 打开移除记录
     */
    function openRemoveHistory() {
      if (!gm.config.removeHistory) {
        message('请在设置中开启稍后再看移除记录')
        return
      }

      var el = {}
      el.searchTimes = null
      if (gm.el.history) {
        el.searchTimes = gm.el.history.querySelector('#gm-search-times')
        el.searchTimes.current = gm.config.removeHistorySearchTimes < gm.config.removeHistoryData.size ? gm.config.removeHistorySearchTimes : gm.config.removeHistoryData.size
        el.searchTimes.value = el.searchTimes.current
        openMenuItem('history')
      } else {
        setTimeout(() => {
          historyInit()
          handleItem()
          openMenuItem('history')
        })

        /**
         * 初始化移除记录页面
         */
        var historyInit = () => {
          gm.el.history = gm.el.gmRoot.appendChild(document.createElement('div'))
          gm.menu.history.el = gm.el.history
          gm.el.history.className = 'gm-history'
          gm.el.history.innerHTML = `
<div class="gm-history-page">
    <div class="gm-title">稍后再看移除记录</div>
    <div class="gm-comment">
        <div>根据最近<span id="gm-save-times">X</span>次打开列表页面时获取到的<span id="gm-record-num">X</span>条记录生成,共筛选出<span id="gm-remove-num">X</span>条移除记录。排序由首次加入到稍后再看的顺序决定,与移除出稍后再看的时间无关。如果记录太多难以定位被误删的视频,请在下方设置减少历史回溯深度。鼠标移动到内容区域可向下滚动翻页,点击对话框以外的位置退出。</div>
        <div style="text-align:right;font-weight:bold;margin-right:1em" title="搜寻时在最近多少次列表页面数据中查找,设置较小的值能较好地定位最近移除的视频。按下回车键或输入框失去焦点时刷新数据。">历史回溯深度:<input type="text" id="gm-search-times" value="X"></div>
    </div>
</div>
<div class="gm-shadow"></div>
`
          el.historyPage = gm.el.history.querySelector('.gm-history-page')
          el.comment = gm.el.history.querySelector('.gm-comment')
          el.content = null
          el.saveTimes = gm.el.history.querySelector('#gm-save-times')
          el.recordNum = gm.el.history.querySelector('#gm-record-num')
          el.removeNum = gm.el.history.querySelector('#gm-remove-num')
          el.shadow = gm.el.history.querySelector('.gm-shadow')
        }

        /**
         * 维护内部元素和数据
         */
        var handleItem = () => {
          // 使用 el.searchTimes.current 代替本地变量记录数据,可以保证任何情况下闭包中都能获取到正确数据
          el.searchTimes = gm.el.history.querySelector('#gm-search-times')
          el.searchTimes.current = gm.config.removeHistorySearchTimes < gm.config.removeHistoryData.size ? gm.config.removeHistorySearchTimes : gm.config.removeHistoryData.size
          el.searchTimes.value = el.searchTimes.current

          var stMax = gm.config.removeHistoryData.size
          var stMin = 1
          el.searchTimes.oninput = function() {
            var v0 = this.value.replace(/[^\d]/g, '')
            if (v0 === '') {
              this.value = ''
            } else {
              var value = parseInt(v0)
              if (value > stMax) {
                value = stMax
              } else if (value < stMin) {
                value = stMin
              }
              this.value = value
            }
          }
          el.searchTimes.onblur = function() {
            if (this.value === '') {
              this.value = stMax
            }
            if (this.value != el.searchTimes.current) {
              el.searchTimes.current = this.value
              gm.menu.history.openHandler()
            }
          }
          el.searchTimes.onkeyup = function(e) {
            if (e.keyCode == 13) {
              this.onblur()
            }
          }

          gm.menu.history.openHandler = onOpen
          window.addEventListener('resize', setContentTop)
          el.shadow.onclick = () => {
            closeMenuItem('history')
          }
        }

        /**
         * 移除记录打开时执行
         */
        var onOpen = () => {
          if (el.content) {
            var oldContent = el.content
            oldContent.style.opacity = '0'
            setTimeout(() => {
              oldContent.remove()
            }, gm.const.textFadeTime)
          }
          el.content = el.historyPage.appendChild(document.createElement('div'))
          el.content.className = 'gm-content'

          GM_xmlhttpRequest({
            method: 'GET',
            url: gm.url.api_queryWatchlaterList,
            onload: function(response) {
              if (response && response.responseText) {
                try {
                  var bvid = []
                  var json = JSON.parse(response.responseText)
                  var watchlaterList = json.data.list
                  for (var e of watchlaterList) {
                    bvid.push(e.bvid)
                  }
                  var map = new Map()
                  var removeData = gm.config.removeHistoryData.toArray(el.searchTimes.current)
                  el.saveTimes.innerText = removeData.length
                  for (var i = removeData.length - 1; i >= 0; i--) { // 后面的数据较旧,从后往前遍历
                    for (var record of removeData[i]) {
                      map.set(record.bvid, record)
                    }
                  }
                  el.recordNum.innerText = map.size
                  for (var id of bvid) {
                    map.delete(id)
                  }
                  var result = []
                  for (var rm of map.values()) {
                    result.push(`<span>${rm.title}</span><br><a href="${gm.url.page_videoNormalMode}/${rm.bvid}" target="_blank">${rm.bvid}</a>`)
                  }
                  el.removeNum.innerText = result.length

                  setContentTop() // 在设置内容前设置好 top,这样看不出修改的痕迹
                  if (result.length > 0) {
                    el.content.innerHTML = result.join('<br><br>')
                  } else {
                    el.content.innerText = `在最近 ${el.searchTimes.current} 次列表页面数据中没有找到被移除的记录,请尝试增大历史回溯深度`
                    el.content.style.color = 'gray'
                  }
                  el.content.style.opacity = '1'
                } catch (e) {
                  var errorInfo = gm.error.NETWORK
                  setContentTop() // 在设置内容前设置好 top,这样看不出修改的痕迹
                  el.content.innerHTML = errorInfo
                  el.content.style.opacity = '1'
                  el.content.style.color = 'gray'

                  console.error(errorInfo)
                  console.error(e)
                }
              }
            }
          })
        }

        var setContentTop = () => {
          if (el.content) {
            el.content.style.top = el.comment.offsetTop + el.comment.offsetHeight + 'px'
          }
        }
      }
    }

    /**
     * 重置脚本数据
     */
    function resetScript() {
      var result = confirm('是否要重置脚本数据?')
      if (result) {
        var gmKeys = GM_listValues()
        for (var gmKey of gmKeys) {
          GM_deleteValue(gmKey)
        }

        gm.configVersion = 0
        GM_setValue('configVersion', gm.configVersion)
        location.reload()
      }
    }

    /**
     * 对“打开菜单项”这一操作进行处理,包括显示菜单项、设置当前菜单项的状态、关闭其他菜单项
     */
    function openMenuItem(name) {
      if (!gm.menu[name].state) {
        for (var key in gm.menu) {
          var menu = gm.menu[key]
          if (key == name) {
            menu.state = true
            menu.openHandler && menu.openHandler()
            fade(true, menu.el)
          } else {
            if (menu.state) {
              closeMenuItem(key)
            }
          }
        }
      }
    }

    /**
     * 对“关闭菜单项”这一操作进行处理,包括隐藏菜单项、设置当前菜单项的状态
     */
    function closeMenuItem(name) {
      var menu = gm.menu[name]
      if (menu.state) {
        menu.state = false
        fade(false, menu.el, () => {
          menu.closeHandler && menu.closeHandler()
        })
      }
    }

    /**
     * 用户通知
     * @param {string} msg 信息
     * @param {Object} [config] 设置
     * @param {boolean} [config.autoClose=true] 是否自动关闭信息,配合 config.ms 使用
     * @param {number} [config.ms=gm.const.messageTime] 显示时间(单位:ms,不含渐显/渐隐时间)
     * @param {boolean} [config.html=false] 是否将 msg 理解为 HTML
     * @param {Object} [config.position=null] 信息框的位置,不设置该项默认为 { top: gm.const.messageTop, left: gm.const.messageLeft }
     * @param {string} config.position.top 信息框元素的 top
     * @param {string} config.position.left 信息框元素的 left
     * @return {HTMLElement} 信息框元素
     */
    function message(msg, config = {}) {
      var defaultConfig = {
        autoClose: true,
        ms: gm.const.messageTime,
        html: false,
        position: null,
      }
      config = { ...defaultConfig, ...config }

      var msgbox = document.body.appendChild(document.createElement('div'))
      msgbox.className = `${gm.id}-msgbox`
      if (config.position) {
        msgbox.style.top = config.position.top
        msgbox.style.left = config.position.left
      }

      if (config.html) {
        msgbox.innerHTML = msg
      } else {
        msgbox.innerText = msg
      }
      fade(true, msgbox, () => {
        if (config.autoClose) {
          setTimeout(() => {
            closeMessage(msgbox)
          }, config.ms)
        }
      })
      return msgbox
    }

    /**
     * 关闭信息
     * @param {HTMLElement} msgbox 信息框元素
     */
    function closeMessage(msgbox) {
      if (msgbox) {
        fade(false, msgbox, () => {
          msgbox && msgbox.remove()
        })
      }
    }

    /**
     * 处理 HTML 元素的渐显和渐隐
     * @param {boolean} inOut 渐显/渐隐
     * @param {HTMLElement} target HTML 元素
     * @param {fadeCallback} [callback] 处理完成后的回调函数
     */
    function fade(inOut, target, callback) {
      // fadeId 等同于当前时间戳,其意义在于保证对于同一元素,后执行的操作必将覆盖前的操作
      var fadeId = new Date().getTime()
      target._fadeId = fadeId
      if (inOut) { // 渐显
        // 只有 display 可视情况下修改 opacity 才会触发 transition
        target.style.display = 'unset'
        setTimeout(() => {
          var success = false
          if (target._fadeId <= fadeId) {
            target.style.opacity = '1'
            success = true
          }
          callback && callback(success)
        }, 10) // 此处的 10ms 是为了保证修改 display 后在浏览器上真正生效,按 HTML5 定义,浏览器需保证 display 在修改 4ms 后保证生效,但实际上大部分浏览器貌似做不到,等个 10ms 再修改 opacity
      } else { // 渐隐
        target.style.opacity = '0'
        setTimeout(() => {
          var success = false
          if (target._fadeId <= fadeId) {
            target.style.display = 'none'
            success = true
          }
          callback && callback(success)
        }, gm.const.fadeTime)
      }
    }

    /**
     * 在条件满足后执行操作
     *
     * 当条件满足后,如果不存在终止条件,那么直接执行 `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)
     * @param {number} [options.timePadding=0] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
     */
    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,
        timePadding: 0,
      }
      var o = {
        ...defaultOptions,
        ...options
      }
      if (!(o.callback instanceof Function)) {
        return
      }

      var tid
      var cnt = 0
      var maxCnt = (o.timeout - o.timePadding) / o.interval
      var task = () => {
        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)
          }
        }
      }
      setTimeout(() => {
        tid = setInterval(task, o.interval)
        task()
      }, o.timePadding)
    }

    /**
     * 在元素加载完成后执行操作
     *
     * 当条件满足后,如果不存在终止条件,那么直接执行 `callback(element)`。
     *
     * 当条件满足后,如果存在终止条件,且 `stopTimeout` 大于 `0`,则还会在接下来的 `stopTimeout` 时间内判断是否满足终止条件,称为终止条件的二次判断。
     * 如果在此期间,终止条件通过,则表示依然不满足条件,故执行 `stopCallback()` 而非 `callback(element)`。
     * 如果在此期间,终止条件一直失败,则顺利通过检测,执行 `callback(element)`。
     *
     * @param {Object} options 选项
     * @param {string} 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 | string} [options.stopCondition] 终止条件。若为函数,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测;若为字符串,则作为元素选择器指定终止元素 `stopElement`,若该元素加载成功则终止检测
     * @param {Function} [options.stopCallback] 终止条件达成时执行 `stopCallback()`(包括终止条件的二次判断达成)
     * @param {number} [options.stopInterval=50] 终止条件二次判断期间的检测时间间隔(单位:ms)
     * @param {number} [options.stopTimeout=0] 终止条件二次判断期间的检测超时时间(单位:ms)
     * @param {number} [options.timePadding=0] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
     */
    function executeAfterElementLoad(options) {
      var defaultOptions = {
        selector: '',
        callback: el => console.log(el),
        interval: 100,
        timeout: 5000,
        onTimeout: null,
        stopCondition: null,
        stopCallback: null,
        stopInterval: 50,
        stopTimeout: 0,
        timePadding: 0,
      }
      var o = {
        ...defaultOptions,
        ...options
      }
      executeAfterConditionPass({
        ...o,
        condition: () => document.querySelector(o.selector),
        stopCondition: () => {
          if (o.stopCondition) {
            if (o.stopCondition instanceof Function) {
              return o.stopCondition()
            } else if (typeof o.stopCondition == 'string') {
              return document.querySelector(o.stopCondition)
            }
          }
        },
      })
    }

    /**
     * 等待条件满足
     * 
     * 执行细节类似于 @{link executeAfterConditionPass}。在原来执行 `callback(result)` 的地方执行 `resolve(result)`,被终止或超时执行 `reject()`。
     * 
     * @async
     * @see executeAfterConditionPass
     * @param {Object} options 选项
     * @param {Function} options.condition 条件,当 condition() 返回的 result 为真值时满足条件
     * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
     * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
     * @param {Function} [options.stopCondition] 终止条件,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测
     * @param {number} [options.stopInterval=50] 终止条件二次判断期间的检测时间间隔(单位:ms)
     * @param {number} [options.stopTimeout=0] 终止条件二次判断期间的检测超时时间(单位:ms)
     * @param {number} [options.timePadding=0] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
     * @returns {Promise}
     */
    async function waitForConditionPass(options) {
      return new Promise((resolve, reject) => {
        executeAfterConditionPass({
          ...options,
          callback: result => resolve(result),
          onTimeout: () => reject(['TIMEOUT', 'waitForConditionPass']),
          stopCallback: () => reject(['STOP', 'waitForConditionPass']),
        })
      })
    }

    /**
     * 等待元素加载
     * 
     * 执行细节类似于 @{link executeAfterElementLoad}。在原来执行 `callback(element)` 的地方执行 `resolve(element)`,被终止或超时执行 `reject()`。
     * 
     * @async
     * @see executeAfterElementLoad
     * @param {Object} options 选项
     * @param {string} options.selector 该选择器指定要等待加载的元素 `element`
     * @param {number} [options.interval=100] 检测时间间隔(单位:ms)
     * @param {number} [options.timeout=5000] 检测超时时间,检测时间超过该值时终止检测(单位:ms)
     * @param {Function | string} [options.stopCondition] 终止条件。若为函数,当 `stopCondition()` 返回的 `stopResult` 为真值时终止检测;若为字符串,则作为元素选择器指定终止元素 `stopElement`,若该元素加载成功则终止检测
     * @param {number} [options.stopInterval=50] 终止条件二次判断期间的检测时间间隔(单位:ms)
     * @param {number} [options.stopTimeout=0] 终止条件二次判断期间的检测超时时间(单位:ms)
     * @param {number} [options.timePadding=0] 等待 `timePadding`ms 后才开始执行;包含在 `timeout` 中,因此不能大于 `timeout`
     * @returns {Promise}
     */
    async function waitForElementLoad(options) {
      return new Promise((resolve, reject) => {
        executeAfterElementLoad({
          ...options,
          callback: element => resolve(element),
          onTimeout: () => reject(['TIMEOUT', 'waitForElementLoad']),
          stopCallback: () => reject(['STOP', 'waitForElementLoad']),
        })
      })
    }

    /**
     * 添加脚本样式
     */
    function addStyle() {
      GM_addStyle(`
#${gm.id} .gm-setting {
    font-size: 12px;
    transition: opacity ${gm.const.fadeTime}ms ease-in-out;
    opacity: 0;
    display: none;
    position: fixed;
    z-index: 10000;
    user-select: none;
}
#${gm.id} .gm-setting .gm-setting-page {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: #ffffff;
    border-radius: 10px;
    z-index: 65535;
    min-width: 48em;
    padding: 1em 1.4em;
}
#${gm.id} .gm-setting #gm-maintitle {
    cursor: pointer;
}
#${gm.id} .gm-setting #gm-maintitle:hover {
    color: #0075FF;
}
#${gm.id} .gm-setting .gm-items {
    margin: 0 2.2em;
    font-size: 1.2em;
}
#${gm.id} .gm-setting .gm-item {
    display: block;
    padding: 0.6em;
}
#${gm.id} .gm-setting .gm-item:hover {
  color: #0075FF;
}
#${gm.id} .gm-setting .gm-subitem {
    display: block;
    margin-left: 6em;
    margin-top: 0.3em;
}
#${gm.id} .gm-setting .gm-subitem[disabled] {
    color: gray;
}
#${gm.id} .gm-setting .gm-subitem:hover:not([disabled]) {
    color: #0075FF;
}
#${gm.id} .gm-setting input[type=checkbox] {
    vertical-align: middle;
    margin: 3px 0 0 10px;
    float: right;
}
#${gm.id} .gm-setting input[type=text] {
    float: right;
    border-width: 0 0 1px 0;
    width: 2.4em;
    text-align: right;
    padding: 0 0.2em;
    margin-right: -0.2em;
}
#${gm.id} .gm-setting select {
    border-width: 0 0 1px 0;
    cursor: pointer;
}
#${gm.id} .gm-setting .gm-warning {
    position: absolute;
    right: 1.4em;
    color: #e37100;
    font-size: 1.4em;
    line-height: 1em;
    transition: opacity ${gm.const.fadeTime}ms ease-in-out;
    opacity: 0;
    display: none;
}
#${gm.id} .gm-setting .gm-bottom {
    margin: 0.8em 2em 1.8em 2em;
    text-align: center;
}
#${gm.id} .gm-setting .gm-bottom button {
    font-size: 1em;
    padding: 0.2em 0.8em;
    margin: 0 0.6em;
    cursor: pointer;
}
#${gm.id} .gm-setting .gm-bottom button[disabled] {
    cursor: not-allowed;
}

#${gm.id} .gm-history {
    transition: opacity ${gm.const.fadeTime}ms ease-in-out;
    opacity: 0;
    display: none;
    position: fixed;
    z-index: 10000;
    user-select: none;
}
#${gm.id} .gm-history .gm-history-page {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    background-color: #ffffff;
    border-radius: 10px;
    z-index: 65535;
    height: 75%;
    width: 60%;
}
#${gm.id} .gm-history .gm-comment {
    margin: 0 2em;
    color: gray;
    text-indent: 2em;
}
#${gm.id} .gm-history .gm-comment span,
#${gm.id} .gm-history .gm-comment input {
    padding: 0 0.2em;
    font-weight: bold;
    color: #666666;
}
#${gm.id} .gm-history .gm-comment input{
    text-align: center;
    width: 3em;
    border-width: 0 0 1px 0;
}
#${gm.id} .gm-history .gm-content {
    margin: 1.6em 2em 2em 2em;
    font-size: 1.2em;
    text-align: center;
    line-height: 1.6em;
    overflow-y: auto;
    position: absolute;
    top: 8em;
    bottom: 0;
    left: 0;
    right: 0;
    opacity: 0;
    transition: opacity ${gm.const.textFadeTime}ms ease-in-out;
    user-select: text;
}
#${gm.id} .gm-history .gm-content::-webkit-scrollbar {
    display: none;
}

#${gm.id} #gm-reset {
    position: absolute;
    right: 0;
    bottom: 0;
    margin: 0.6em 0.8em;
    color: #b4b4b4;
    cursor: pointer;
}
#${gm.id} #gm-reset:hover {
    color: #666666;
}

#${gm.id} .gm-title {
    font-size: 1.6em;
    margin: 1.6em 0.8em 0.8em 0.8em;
    text-align: center;
}

#${gm.id} .gm-subtitle {
    font-size: 0.4em;
    margin-top: 0.4em;
}

#${gm.id} .gm-shadow {
    background: #000000b0;
    position: fixed;
    top: 0%;
    left: 0%;
    z-index: 10000;
    width: 100%;
    height: 100%;
}
#${gm.id} .gm-shadow[disabled] {
    cursor: auto;
}

#${gm.id} label {
    cursor: pointer;
}
#${gm.id} input,
#${gm.id} select {
    color: black;
}

#${gm.id} [disabled],
#${gm.id} [disabled] input,
#${gm.id} [disabled] select {
    cursor: not-allowed;
    color: gray;
}

#${gm.id}-watchlater-video-btn {
    float: left;
    margin-right: 1em;
    cursor: pointer;
    font-size: 12px;
}
#${gm.id}-normal-video-btn input[type=checkbox],
#${gm.id}-watchlater-video-btn input[type=checkbox] {
    vertical-align: middle;
    margin: 0 2px 2px 0;
}

.${gm.id}-msgbox {
    position: fixed;
    top: ${gm.const.messageTop};
    left: ${gm.const.messageLeft};
    transform: translate(-50%, -50%);
    z-index: 65535;
    background-color: #000000bf;
    font-size: 16px;
    max-width: 24em;
    min-width: 2em;
    color: white;
    padding: 0.5em 1em;
    border-radius: 0.6em;
    opacity: 0;
    transition: opacity ${gm.const.fadeTime}ms ease-in-out;
    user-select: none;
}
      `)
    }
  })

  // 一般情况下,读取用户配置;如果配置出错,则沿用默认值,并将默认值写入配置中
  function gmValidate(gmKey, defaultValue, writeBack = true) {
    var value = GM_getValue(gmKey)
    if (typeof value == typeof defaultValue) { // typeof null == 'object',对象默认值赋 null 无需额外处理
      return value
    } else {
      if (writeBack) {
        GM_setValue(gmKey, defaultValue)
      }
      return defaultValue
    }
  }

  /**
   * document-start 时期初始化
   */
  function initAtDocumentStart() {
    // document-start 级用户配置读取
    if (gm.configVersion > 0) {
      gm.config.redirect = gmValidate('redirect', gm.config.redirect)
    } else {
      GM_setValue('redirect', gm.config.redirect)
    }
  }

  /**
   * 稍后再看模式重定向至正常模式播放
   */
  function fnRedirect() {
    window.stop() // 停止原页面的加载
    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 part = 1
            if (urlMatch(/watchlater\/p\d+/)) {
              part = parseInt(location.href.match(/(?<=\/watchlater\/p)\d+(?=\/?)/)[0])
            } // 如果匹配不上,就是以 watchlater/ 直接结尾,等同于 watchlater/p1
            var json = JSON.parse(response.responseText)
            var watchlaterList = json.data.list
            location.replace('https://www.bilibili.com/video/' + watchlaterList[part - 1].bvid)
          } catch (e) {
            var errorInfo = `重定向错误,重置脚本数据也许能解决问题。无法解决请联系脚本作者:${GM_info.script.supportURL}`
            console.error(errorInfo)
            console.error(e)

            var rc = confirm(errorInfo + '\n\n是否暂时关闭模式切换功能?')
            if (rc) {
              gm.config.redirect = false
              GM_setValue('redirect', gm.config.redirect)
              location.reload()
            } else {
              location.replace('https://www.bilibili.com/watchlater/#/list')
            }
          }
        }
      }
    })
  }

  /**
   * 判断当前 URL 是否匹配
   * @param {RegExp} reg 用于判断是否匹配的正则表达纯
   * @returns {boolean} 是否匹配
   */
  function urlMatch(reg) {
    return reg.test(location.href)
  }

  /**
   * 推入队列,循环数组实现
   * @constructor
   * @param {number} maxSize 队列的最大长度,达到此长度后继续推入数据,将舍弃末尾处的数据
   * @param {number} [capacity=maxSize] 容量,即循环数组的长度,不能小于 maxSize
   */
  function PushQueue(maxSize, capacity) {
    this.index = 0
    this.size = 0
    this.maxSize = maxSize
    if (!capacity || capacity < maxSize) {
      capacity = maxSize
    }
    this.capacity = capacity
    this.data = new Array(capacity)
  }
  /**
   * 设置推入队列的最大长度
   * @param {number} maxSize 队列的最大长度,不能大于 capacity
   */
  PushQueue.prototype.setMaxSize = function(maxSize) {
    if (maxSize > this.capacity) {
      maxSize = this.capacity
    } else if (maxSize < this.size) {
      this.size = maxSize
    }
    this.maxSize = maxSize
    this.gc()
  }
  /**
   * 重新设置推入队列的容量
   * @param {number} capacity 容量
   */
  PushQueue.prototype.setCapacity = function(capacity) {
    if (this.maxSize > capacity) {
      this.maxSize = capacity
      if (this.size > capacity) {
        this.size = capacity
      }
      // no need to gc()
    }
    var raw = this.toArray()
    var data = [...raw.reverse()]
    this.index = data.length
    data.length = capacity
    this.data = data
  }
  /**
   * 队列是否为空
   */
  PushQueue.prototype.empty = function() {
    return this.size == 0
  }
  /**
   * 向队列中推入数据,若队列已达到最大长度,则舍弃末尾处数据
   * @param {*} value 推入队列的数据
   */
  PushQueue.prototype.push = function(value) {
    this.data[this.index] = value
    this.index += 1
    if (this.index >= this.capacity) {
      this.index = 0
    }
    if (this.size < this.maxSize) {
      this.size += 1
    }
    if (this.maxSize < this.capacity && this.size == this.maxSize) { // maxSize 等于 capacity 时资源刚好完美利用,不必回收资源
      var release = this.index - this.size - 1
      if (release < 0) {
        release += this.capacity
      }
      this.data[release] = null
    }
  }
  /**
   * 将队列末位处的数据弹出
   * @returns {*} 弹出的数据
   */
  PushQueue.prototype.pop = function() {
    if (this.size > 0) {
      var index = this.index - this.size
      if (index < 0) {
        index += this.capacity
      }
      this.size -= 1
      var result = this.data[index]
      this.data[index] = null
      return result
    }
  }
  /**
   * 将推入队列以数组的形式返回
   * @param {number} [maxLength=size] 读取的最大长度
   * @returns {Array} 队列数据的数组形式
   */
  PushQueue.prototype.toArray = function(maxLength) {
    if (typeof maxLength != 'number') {
      maxLength = parseInt(maxLength)
    }
    if (isNaN(maxLength) || maxLength > this.size || maxLength < 0) {
      maxLength = this.size
    }
    var ar = []
    var end = this.index - maxLength
    for (var i = this.index - 1; i >= end && i >= 0; i--) {
      ar.push(this.data[i])
    }
    if (end < 0) {
      end += this.capacity
      for (i = this.capacity - 1; i >= end; i--) {
        ar.push(this.data[i])
      }
    }
    return ar
  }
  /**
   * 清理内部无效数据,释放内存
   */
  PushQueue.prototype.gc = function() {
    if (this.size > 0) {
      var start = this.index - 1
      var end = this.index - this.size
      if (end < 0) {
        end += this.capacity
      }
      if (start >= end) {
        for (var i = 0; i < end; i++) {
          this.data[i] = null
        }
        for (i = start + 1; i < this.capacity; i++) {
          this.data[i] = null
        }
      } else if (start < end) {
        for (i = start + 1; i < end; i++) {
          this.data[i] = null
        }
      }
    } else {
      this.data = new Array(this.capacity)
    }
  }
})()