bilibili - display video av, bv number below the title bar

Display the video av, bv, cid number below the title bar of the bilibili play page, support copy short link

// ==UserScript==
// @name                bilibili - display video av, bv number below the title bar
// @name:zh-CN          b 站 - 显示视频 av 号、bv 号及弹幕 cid
// @namespace           https://gist.github.com/phtwo
// @version             0.3.2
// @licence             MIT. Copyright (c) phtwo.
// @description         Display the video av, bv, cid number below the title bar of the bilibili play page, support copy short link
// @description:zh-CN   在 b 站播放页标题栏下面显示视频 av 号、bv 号及弹幕 cid,支持复制短链接
// @match               *://www.bilibili.com/video/*
// @grant               GM_addStyle
//
// @author              phtwo
// @homepage            https://gist.github.com/phtwo/e2d8c04707ed6a6369a55be37e3f14c7
// @supportURL          https://gist.github.com/phtwo/e2d8c04707ed6a6369a55be37e3f14c7
//
// @noframes
// @nocompat Chrome
//
// ==/UserScript==

/**
 * @licence MIT. Copyright (c) Feross Aboukhadijeh.
 * @description From https://github.com/feross/clipboard-copy
 * @param text
 * @returns {Promise<void>|any}
 */
function clipboardCopy(text) {
  // Use the Async Clipboard API when available. Requires a secure browsing
  // context (i.e. HTTPS)
  if (navigator.clipboard) {
    return navigator.clipboard.writeText(text).catch(function (err) {
      throw err !== undefined
        ? err
        : new DOMException('The request is not allowed', 'NotAllowedError')
    })
  }

  // ...Otherwise, use document.execCommand() fallback

  // Put the text to copy into a <span>
  const span = document.createElement('span')
  span.textContent = text

  // Preserve consecutive spaces and newlines
  span.style.whiteSpace = 'pre'

  // Add the <span> to the page
  document.body.appendChild(span)

  // Make a selection object representing the range of text selected by the user
  const selection = window.getSelection()
  const range = window.document.createRange()
  selection.removeAllRanges()
  range.selectNode(span)
  selection.addRange(range)

  // Copy text to the clipboard
  let success = false
  try {
    success = window.document.execCommand('copy')
  } catch (err) {
    console.log('error', err)
  }

  // Cleanup
  selection.removeAllRanges()
  window.document.body.removeChild(span)

  return success
    ? Promise.resolve()
    : Promise.reject(
        new DOMException('The request is not allowed', 'NotAllowedError')
      )
}

;(function () {
  const classPref = 'p-bevd'

  const styleCssText = `
.${classPref}-data-item {
  position: relative!important;
  color: #afafaf!important;
}

.${classPref}-data-item:hover .${classPref}-hover-box {
  display: inline-block!important;
}

.${classPref}-hover-box {
  display: none!important;
  vertical-align: top!important;
  
  position: absolute!important;
  bottom: 1.2em;
  right: 0;
  padding: 0 0 0 10px!important;
}

.${classPref}-copy-link {
  color: #afafaf!important;
}

.${classPref}-copy-link:hover {
  color: #00a1d6!important;
}
  `

  const urlNodeText = '短链接'

  const list = []

  /** @type {Element} */
  let videoDataLineElem

  init()

  function init() {
    console.log('[bilibili_extra_video_data] init')

    initVideoDataLineElem()
    if (!videoDataLineElem) {
      return
    }

    util_addStyle(styleCssText)

    start().catch((err) => {
      console.error(
        '[bilibili_extra_video_data] 发生未知错误,若刷新仍报错,可能为页面更新导致,',
        err
      )
    })
  }

  async function start() {
    await waitingForTheFirstInjection()

    initVideoDataChangeMonitor()
    doInject()
  }

  async function waitingForTheFirstInjection() {
    let lastElem

    lastElem = await waitingForChildElement(
      document.querySelector('#bilibili-player'),
      '.bilibili-player-video-sendbar',
      true
    )
    lastElem = await waitingForChildElement(
      lastElem,
      '.bilibili-player-video-info-people-number',
      true
    )

    console.log(
      '[bilibili_extra_video_data] Element(video-info-people-number) display'
    )

    // observeElementChange(
    //   lastElem,
    //   ({ off }) => {
    //     console.log(
    //       '[bilibili_extra_video_data] Element(video-info-people-number)'
    //     )
    //   },
    //   {
    //     attributes: false,
    //     characterData: true,
    //     characterDataOldValue: true,
    //   }
    // )

    // Trick Code: 检测到上述元素后,设置 2s 的延时
    await util_delay(2e3)
  }

  function initVideoDataLineElem() {
    videoDataLineElem = document.querySelector(
      '#viewbox_report .video-data:nth-of-type(1)'
    )
  }

  function initVideoDataChangeMonitor() {
    observeElementChange(videoDataLineElem.children[0], doInject, {
      attributeFilter: ['title'],
    })
  }

  function doInject() {
    const { aid, bvid, cid } = unsafeWindow

    list.length = 0 // clear list
    addItem('av', aid, true)
    addItem('', bvid, true)
    addItem('cid', cid)

    const fragment = createFragment()

    const referenceNode = videoDataLineElem.querySelector('.copyright')
    removeOldNodes()
    videoDataLineElem.insertBefore(fragment, referenceNode)

    console.log('[bilibili_extra_video_data] doInject finished')
  }

  function createFragment() {
    const firstPrefix = '&nbsp;&nbsp;'
    const prefix = '&nbsp;·&nbsp;'

    const nodeList = list.map((item, index) => {
      let text = item.prefix + item.value
      let innerHTML = (index === 0 ? firstPrefix : prefix) + text

      const elem = document.createElement('span')
      elem.title = text
      elem.innerHTML = innerHTML
      elem.classList.add(`${classPref}-data-item`)
      elem.setAttribute('data-inject', '')

      item.hasShortLink && elem.append(createHoverElem(text))

      return elem
    })

    const fragment = document.createDocumentFragment()
    fragment.append(...nodeList)
    return fragment
  }

  function createHoverElem(id) {
    const aNode = document.createElement('a')
    aNode.classList.add(`${classPref}-copy-link`)
    aNode.href = 'https://b23.tv/' + id
    aNode.textContent = urlNodeText
    aNode.title = '单击复制,右键打开菜单'

    aNode.addEventListener('click', copyUrlOnClick)

    const e = document.createElement('span')
    e.classList.add(`${classPref}-hover-box`)
    e.append(aNode)
    return e
  }

  function copyUrlOnClick(ev) {
    ev.preventDefault()
    ev.stopPropagation()

    const target = ev.target
    const text = target.href || ''

    clipboardCopy(text).then(
      async () => {
        console.log(
          '[bilibili_extra_video_data] copy [%s] to clipboard succeeded.',
          text
        )
        target.textContent = '已复制!'
        await util_delay(1e3)
        target.textContent = urlNodeText
      },
      () => {
        console.error(
          '[bilibili_extra_video_data] copy [%s] to clipboard failed.',
          text
        )
        window.prompt('自动复制失败,请手动复制', text)
      }
    )
  }

  function removeOldNodes() {
    videoDataLineElem
      .querySelectorAll('[data-inject]')
      .forEach((node) => node.parentNode.removeChild(node))
  }

  function addItem(prefix = '', value, hasShortLink = false) {
    value && list.push({ prefix, value, hasShortLink })
  }

  // ------

  /**
   * @name waitingForChildElement
   * @description 使用 MutationObserver 接口,等待父元素后代中匹配指定 CSS 选择器元素的出现并返回该元素
   * @param {Node | string} parent
   * @param {string} childSelectors - 使用 Element.matches 匹配
   * @param {boolean} subtree
   * @return {Promise<Element>}
   */
  function waitingForChildElement(parent, childSelectors, subtree = false) {
    // 先判断 后代元素 是否存在,存在立即返回
    // 不存在,再使用 MutationObserver 监视

    const deferred = createPromiseDeferred()

    const parentElem =
      typeof parent === 'string' ? document.querySelector(parent) : parent

    const targetChildElem = subtree
      ? parentElem.querySelector(childSelectors)
      : [...parentElem.children].find((elem) => elem.matches(childSelectors))

    if (targetChildElem) {
      deferred.resolve(targetChildElem)
      return deferred.promise
    }

    const options = {
      childList: true,
      subtree,
    }
    const observer = new MutationObserver(mutationCallback)
    observer.observe(parentElem, options)

    return deferred.promise

    function mutationCallback(/** @type {Array<MutationRecord>} */ recordList) {
      for (const record of recordList) {
        let targetNode

        for (const node of [...record.addedNodes]) {
          if (1 !== node.nodeType) {
            continue
          }
          if (node.matches(childSelectors)) {
            targetNode = node
            break
          }
          if (subtree) {
            let tNode = node.querySelector(childSelectors)
            if (tNode) {
              targetNode = tNode
              break
            }
          }
        }

        if (targetNode) {
          observer.disconnect()
          deferred.resolve(targetNode)
          break
        }
      }
    }
  }

  /**
   * observeElementChange
   * @description 监听元素属性的变化。 ps: 批量更新多个属性时仅触发一次
   * @param {Element} target
   * @param {Function} callback
   * @param {Object} options
   */
  function observeElementChange(target, callback, options) {
    const observer = new MutationObserver((recordList) => {
      observer.takeRecords()
      callback({
        off,
      })
    })

    observer.observe(target, {
      attributes: true,
      // attributeFilter: [],
      // subtree: true,
      // childList: true,
      // characterData: true,
      ...options,
    })

    function off() {
      observer.disconnect()
    }
  }

  // ------

  function createPromiseDeferred() {
    let _resolve, _reject
    let promise = new Promise((resolve, reject) => {
      _resolve = resolve
      _reject = reject
    })

    return {
      promise,
      resolve: _resolve,
      reject: _reject,
    }
  }

  function util_addStyle(css) {
    if (typeof GM_addStyle !== 'undefined') {
      return GM_addStyle(css)
    }

    const styleNode = document.createElement('style')
    styleNode.appendChild(document.createTextNode(css))
    ;(document.querySelector('head') || document.documentElement).appendChild(
      styleNode
    )
    return styleNode
  }

  function util_delay(timeout = 300, doReject) {
    return new Promise((resolve, reject) => {
      setTimeout(doReject ? reject : resolve, timeout)
    })
  }
})()