哔哩哔哩宽屏

哔哩哔哩宽屏体验

目前为 2023-12-09 提交的版本。查看 最新版本

// ==UserScript==
// @name            哔哩哔哩宽屏
// @name:en         Wider Bilibili
// @namespace       https://greasyfork.org/users/1125570
// @description     哔哩哔哩宽屏体验
// @description:en  BiliBili, but wider
// @version         0.3.3
// @author          posthumz
// @license         MIT
// @match           http*://*.bilibili.com/*
// @icon            https://www.bilibili.com/favicon.ico
// @grant           GM_addStyle
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_registerMenuCommand
// @grant           GM_addValueChangeListener
// @noframes
// ==/UserScript==

(async () => {
  'use strict'

  const styles = {
    home:
`.feed-card, .floor-single-card, .bili-video-card {
  margin-top: 0px !important;
}
.feed-roll-btn {
  left: initial !important;
  right: calc(10px - var(--layout-padding));
}
.palette-button-outer {
  padding: 0;
}
.palette-button-wrap {
  left: initial !important;
  right: 10px;
}`,

    t:
`.bili-dyn-home--member {
  margin: 0 var(--layout-padding) !important;

  main {
    flex: 1
  }
}
`,

    read:
`.article-detail {
  width: 90%;

  .article-up-info {
    width: initial;
    margin: 0 80px 20px;
  }
  .right-side-bar {
    right: 0;
  }
}`,

    video:
`/* 播放器 */
#bilibili-player {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
}

#playerWrap,
#bilibili-player-wrap {
  position: relative;
  height: 100vh;
  min-height: 20vh;
  padding: 0;
}

/* 小窗 */
.bpx-player-container[data-screen="mini"] {
  height: auto !important; /* 以视频长宽比为准,且不显示黑边 */
  transform: translateY(24px) !important;
}

.bpx-player-container:not([data-screen="mini"]) {
  width: 100% !important;
}

/* 视频标题换行显示 */
#viewbox_report {
  height: auto;
}

.video-title {
  white-space: normal !important;
}

/* 视频页, 番剧页, 收藏(包括稍后再看)页 下方容器 */
.video-container-v1, .main-container, .playlist-container {
  z-index: 0;
  padding: 0 var(--layout-padding);
}

.left-container, .plp-l, .playlist-container--left {
  flex: 1;
}

.plp-r {
  padding-top: 0 !important;
}

/* 番剧/影视页下方容器 */
.main-container {
  width: 100%;
  margin: 0;
  padding: 15px 50px 15px 25px !important;
  box-sizing: border-box;
  display: flex;
}

.player-left-components {
  padding-right: 30px !important;
}

.toolbar {
  padding-top: 0;
}

/* 播放器控件 */
.bpx-player-top-left-title, .bpx-player-top-left-music {
  display: block !important;
}

.bpx-player-control-bottom {
  padding: 0 24px;
}

.bpx-player-control-bottom-left,
.bpx-player-control-bottom-right,
.bpx-player-sending-bar,
.be-video-control-bar-extend {
  gap: 10px;

  >:not(.bpx-player-ctrl-time) {
    width: auto !important;
    margin: 0 !important;
  }
}

.bpx-player-control-bottom-left {
  min-width: initial !important;
}

.bpx-player-control-bottom-center {
  padding: 0 30px !important;
}

.bpx-player-control-bottom-right {
  min-width: initial !important;
}

.bpx-player-ctrl-time-label {
  text-align: center !important;
  text-indent: 0 !important;
}

.bpx-player-ctrl-time, .bpx-player-ctrl-quality {
  margin-right: 0 !important;
}

.bpx-player-video-inputbar {
  min-width: initial !important;
}

/* 右下方浮动按钮 */
div[class^=navTools_floatNav] {
  z-index: 1 !important;
}

/* 笔记位移 (不然笔记会超出页面初始范围) */
.note-pc {
  transform: translate(-12px, 64px);
}

/* 导航栏 (兼容Bilibili Evolved自定义导航栏) */
#biliMainHeader, .custom-navbar {
  position: sticky !important;
  top: 0;
  z-index: 3 !important;
}

#biliMainHeader > .bili-header {
  min-height: 0 !important;
}

.bili-header__bar {
  position: relative !important;
}

/* Bilibili Evolved 夜间模式修正 */
.bpx-player-container .bpx-player-sending-bar {
  background-color: transparent !important;
}

.bpx-player-container .bpx-player-video-info {
  color: hsla(0,0%,100%,.9) !important;
}

.bpx-player-container .bpx-player-sending-bar .bpx-player-video-btn-dm,
.bpx-player-container .bpx-player-sending-bar .bpx-player-dm-setting,
.bpx-player-container .bpx-player-sending-bar .bpx-player-dm-switch {
  fill: hsla(0,0%,100%,.9) !important;
}

/* Bilibili Evolved侧栏 */
.be-settings {
  z-index: 3;
  position: fixed;
}`,

    mini:
`.bpx-player-container {
  min-width: 180px;
}
.bpx-player-mini-resizer {
  position: absolute;
  left: 0;
  width: 10px;
  height: 100%;
  cursor: ew-resize;
}`,

    common:
`:root {
  --layout-padding: ${GM_getValue('左右边距', 30)}px;
}

/* 搜索栏 */
.center-search-container {
  min-width: 0;
}

.nav-search-input {
  padding-right: 0;
}

.nav-search-clean {
  display: none;
}

/* 脚本设置样式 */
#WBOptions {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 114514;

  border-radius: 15px;
  padding: 20px;
  display: none;
  grid-template-columns: repeat(2, 1fr);
  gap: 20px 30px;

  background-color: var(--bg1);
  color: var(--text1);
  box-shadow: 0 0 4px #00a0d8;
  font-size: 18px;
}

#WBOptionsClose {
  position: absolute;
  border: none;
  right: 0;
  font-size: 30px;
  line-height: 30px;
  width: 30px;
  border-top-right-radius: 15px;
  border-bottom-left-radius: 5px;
  transition: .1s;
  background-color: transparent;
  color: var(--text1);
}

#WBOptionsClose:hover {
  background-color: #e81123;
}

#WBOptionsClose:active {
  opacity: 0.5;
}

#WBOptions>header {
  grid-column: 1/-1;
}

#WBOptions>label {
  align-items: center;
  display: flex;
  gap: 10px;
}

#WBOptions input {
  height: 20px;
  margin: 0;
  padding: 4px !important;
  box-sizing: content-box !important;
}

.slider::before {
  content: "";
  display: block;
  position: relative;

  height: 100%;
  aspect-ratio: 1/1;
  border-radius: 50%;
  background-color: #fff;
  transition: .4s;
}

.slider {
  appearance: none;
  width: 40px;
  border-radius: 20px;
  box-sizing: content-box;
  cursor: pointer;
  background-color: #ccc;
  transition: .4s;
}

.slider:checked::before {
  transform: translateX(20px);
}

.slider:checked {
  background-color: #00a0d8;
}

.slider:hover {
  box-shadow: 0 0 4px #00a0d8;
}

.slider:active {
  opacity: 0.5;
}

#WBOptions input[type=number] {
  width: 60px;
  border: none;
  border-radius: 5px;
  background: transparent;
  box-shadow: 0 0 0 2px #00a0d8;
  color: var(--text1) !important;
}`}

  GM_addStyle(styles.common)

  // 设置选项功能
  const options = document.body.appendChild(document.createElement('div'))
  options.id = 'WBOptions'
  options.innerHTML =`<button id="WBOptionsClose">×</button>
<header>⚙️宽屏选项</header>
<label><input type="checkbox" class="slider"${GM_getValue('导航栏下置', true) ? ' checked': ''}>导航栏下置</label>
<label><input type="number" placeholder="px" min="0" value="${GM_getValue('左右边距', 30)}">左右边距</label>`

  // 调出设置选项
  GM_registerMenuCommand('选项', () => { options.style.display = 'grid' })
  // 关闭设置选项
  options.getElementsByTagName('button')[0]?.addEventListener('click', () => { options.style.display = 'none' })

  for (const Element of options.children) {
    if (Element instanceof HTMLLabelElement) {
      const input = Element.getElementsByTagName('input')[0]
      const key = Element.textContent ?? ''
      switch (input?.type) {
      case 'checkbox':
        input.onchange = () => {
          GM_setValue(key, input.checked)
        }
        break
      case 'number':
        input.oninput = () => {
          const val = Number(input.value)
          if (Number.isInteger(val)) {
            GM_setValue(key, val)
          }
        }
        break
      default:
        console.error('啊?')
        break
      }
    }
  }

  GM_addValueChangeListener('左右边距', (_n, _o, newVal) =>
    document.documentElement.style.setProperty('--layout-padding', `${newVal}px`))

  /**
   * @param {() => any} loaded
   * @description 每一定时间检测某个条件是否满足,超时则reject
   */
  const waitFor = (loaded, retry = 100, interval = 100) => new Promise((resolve, reject) => {
    const intervalID = setInterval(() => {
      if (--retry == 0) {
        console.error('页面加载超时')
        clearInterval(intervalID)
        return reject()
      }
      if (loaded()) {
        clearInterval(intervalID)
        return resolve(undefined)
      }
      if (retry % 10 == 0) { console.debug('等待页面加载') }
    }, interval)
  })

  const url = new URL(window.location.href)
  switch (url.host) {
  case 't.bilibili.com':
    GM_addStyle(styles.t)
    console.info('使用动态样式')
    break

  case 'www.bilibili.com': {
    // #region 首页
    if (document.getElementById('i_cecream')) {
      GM_addStyle(styles.home)
      console.info('使用首页宽屏样式')
      break
    }
    // #endregion

    // #region 阅读页
    if (document.getElementsByClassName('article-detail')[0]) {
      GM_addStyle(styles.read)
      console.info('使用阅读页宽屏样式')
      break
    }
    // #endregion

    // #region 播放页
    // 播放器不存在时不执行
    const player = document.getElementById('bilibili-player')
    if (!player) { return console.info('未找到播放器,不启用宽屏模式') }

    // 主容器,视频播放页为#app,番剧/影视播放页为.home-container
    const home = document.getElementById('app') ?? document.getElementsByClassName('home-container')[0]
    // 播放器外容器,视频播放页为#playerWrap,番剧/影视播放页为#bilibili-player-wrap
    const wrap = document.getElementById('playerWrap') ?? document.getElementById('bilibili-player-wrap')
    // 在新版本页面,播放器存在时都应该存在
    if (!wrap || !home) { return console.error(
      `页面加载错误:${[
        wrap ? '' : '播放器外容器',
        home ? '' : '主容器',
      ].filter(Boolean).join(', ')},请检查是否为新版页面`
    ) }

    // 等待人数加载
    const b = player.getElementsByTagName('b')
    await waitFor(() => b[0]?.textContent != null)
    await waitFor(() => b[0]?.textContent != '-')

    // 导航栏 (兼容Bilibili Evolved自定义顶栏,有可能延后加载)
    const navigation = await (async () => {
      const header = document.getElementById('biliMainHeader')
      if (header) {
        header.style.setProperty('height', 'initial', 'important')
        home.insertAdjacentElement('afterbegin', header)
        // bili-header__bar不可见时使用自定义顶栏
        const headerBar = header.getElementsByClassName('bili-header__bar')[0]
        if (headerBar && window.getComputedStyle(headerBar).display == 'none') {
          const navbar = document.getElementsByClassName('custom-navbar')
          await waitFor(() => navbar[0])
          navbar[0] && home.insertAdjacentElement('afterbegin', navbar[0])
        }
      }
      return header
    })()
    // 播放器内容器
    const container = player.getElementsByClassName('bpx-player-container')[0]
    // 播放器底中部框 (用于放置弹幕框内容)
    const bottomCenter = (() => {
      const center = player.getElementsByClassName('bpx-player-control-bottom-center')[0]
      // 番剧版使用squirtle-controller-wrap-center,但也存在bpx-player-control-bottom-center
      // 所以通过检测前一个元素(bpx-player-control-bottom-left)是否有子元素来判断使用哪个
      return center?.previousElementSibling?.hasChildNodes() ? center
        : player.getElementsByClassName('squirtle-controller-wrap-center')[0]
    })()
    // 弹幕框
    const danmaku = player.getElementsByClassName('bpx-player-sending-bar')[0]

    // 正常情况应该都存在
    if (!navigation || !(container instanceof HTMLDivElement) || !bottomCenter || !danmaku) {
      return console.error(
        `页面加载错误:${[
          navigation ? '' : '导航栏',
          container ? '' : '播放器内容器',
          bottomCenter ? '' : '播放器底中部框',
          danmaku ? '' : '弹幕框',
        ].filter(Boolean).join(', ')}`
      )
    }

    // 改变导航栏位置,true为视频下方,false为视频上方,默认为下方
    const lowerNavigation = (value = true) => {
      if (value) {
        wrap.style.removeProperty('height')
        navigation.insertAdjacentElement('beforebegin', wrap)
      } else {
        wrap.style.height = `calc(100vh - ${navigation.clientHeight}px)`
        navigation.insertAdjacentElement('afterend', wrap)
      }
      return value
    }
    lowerNavigation(GM_getValue('导航栏下置', true))

    GM_addValueChangeListener('导航栏下置', (_n, _o, newVal) => { lowerNavigation(newVal) })

    // 使用宽屏样式 (除非当前是小窗模式)
    if (container.getAttribute('data-screen') != 'mini') {
      container.setAttribute('data-screen', 'web')
    }
    // 重载container的setAttribute:data-screen被设置为mini(小窗)以外的值时将其设置为web(宽屏)
    const setAttributeContainer = container.setAttribute.bind(container)
    container.setAttribute = (name, value) =>
      setAttributeContainer(name, name == 'data-screen' && value != 'mini' ? 'web' : value)
    // 番剧页面需要初始与退出全屏时移除#bilibili-player-wrap的class
    if (wrap.id == 'bilibili-player-wrap') {
      wrap.className = ''
      document.addEventListener('fullscreenchange',
        () => { document.fullscreenElement ?? (wrap.className = '') }
      )
    }
    // 退出全屏时弹幕框移至播放器下方
    document.addEventListener('fullscreenchange',
      () => { document.fullscreenElement ?? bottomCenter.replaceChildren(danmaku) }
    )

    // 移除原 宽屏/网页全屏 按钮,因为没有用了
    for (const className of [
      'bpx-player-ctrl-wide', 'bpx-player-ctrl-web',
      'squirtle-widescreen-wrap', 'squirtle-pagefullscreen-wrap',
    ]) { player.getElementsByClassName(className)[0]?.remove() }

    // 添加视频样式
    GM_addStyle(styles.video)

    // 将弹幕框移至播放器下方一次
    bottomCenter.replaceChildren(danmaku)

    // 将笔记移至主容器,不然会被视频和导航栏遮挡
    const note = document.getElementsByClassName('note-pc')[0]
    if (note) { document.body.appendChild(note) }

    console.info('宽屏模式成功启用')

    // #region 小窗
    GM_addStyle(styles.mini)

    const miniResizer = document.createElement('div')
    miniResizer.className = 'bpx-player-mini-resizer'
    miniResizer.onmousedown = ev => {
      ev.stopImmediatePropagation()
      ev.preventDefault()

      /** @param {MouseEvent} ev */
      const resize = ev => {
        container.style.width = `${container.offsetWidth + container.offsetLeft - ev.x + 1}px`
      }
      document.addEventListener('mousemove', resize)
      document.addEventListener('mouseup', () => document.removeEventListener('mousemove', resize), {once: true})
    }

    // 小窗模式下添加拖动调整大小的部件,若直接添加失败则通过MutationObserver监听添加
    if (!container.getElementsByClassName('bpx-player-mini-warp')[0]?.appendChild(miniResizer)) {
      const videoArea = container.getElementsByClassName('bpx-player-video-area')[0]
      videoArea && new MutationObserver((mutations, observer) => {
        mutations.filter(mutation => mutation.type == 'childList').forEach(mutation =>
          mutation.addedNodes.forEach(node => {
            if (node instanceof Element && node.classList.contains('bpx-player-mini-warp')) {
              node.appendChild(miniResizer)
              observer.disconnect()
            }
          })
        )
      }
      ).observe(videoArea, {childList: true})
    }
    // #endregion

    break
    // #endregion
  }

  default:
    console.info('未知页面')
    break
  }
})()