Wider Bilibili

哔哩哔哩宽屏体验

目前为 2024-03-07 提交的版本。查看 最新版本

// ==UserScript==
// @name            Wider Bilibili
// @name:zh         哔哩哔哩宽屏
// @namespace       https://greasyfork.org/users/1125570
// @description     哔哩哔哩宽屏体验
// @description:en  BiliBili, but wider
// @version         0.3.5.2
// @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 function () {
  'use strict'

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

html, body {
  width: initial !important;
  height: initial !important;
}

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

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

/* 脚本设置样式 */
#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);
  outline: 4px solid #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;
  font-size: 16px;
}

#WBOptions input[type=checkbox] {
  width: 40px;
  appearance: none;
  border-radius: 20px;
  box-sizing: content-box;
  cursor: pointer;
  background-color: #ccc;
  transition: .2s;
}

#WBOptions input[type=checkbox]::before {
  content: "";
  display: flex;
  position: relative;

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

#WBOptions input[type=checkbox]:checked {
  background-color: #00a0d8;
}

#WBOptions input[type=checkbox]:checked::before {
  transform: translateX(20px);
}

#WBOptions input[type=checkbox]:hover {
  box-shadow: 0 0 4px #00a0d8;
}

#WBOptions input[type=checkbox]:active {
  opacity: 0.5;
}

#WBOptions input[type=number] {
  width: 60px;
  border: none;
  border-radius: 5px;
  background: none;
  outline: 2px solid #00a0d8;
  color: var(--text1) !important;
  appearance: textfield;
}

#WBOptions input[type=number]::-webkit-inner-spin-button {
  appearance: none;
}`,
    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
  }

  .left {
    display: none;
  }
}
`,
    space: `/* 空间页 */
.wrapper, .search-page {
  width: initial !important;
  margin: 0 var(--layout-padding) !important;
}

/* 视频卡片 */
.small-item {
  padding-left: 10px !important;
  padding-right: 10px !important;
}

/* 主页, 动态 */
#page-index, #page-dynamic {
  display: flex;
  justify-content: space-between;
  gap: 10px;

  &::before, &::after {
    content: none;
  }

  .col-1 {
    flex: 1;

    >.video>.content {
      display: flex;
      flex-wrap: wrap;
    }
  }

  .channel>.content {
    width: initial !important;

    .channel-video {
      overflow-x: auto;
    }
  }

  .fav-item {
    margin-right: 20px !important;
  }
}

/* 投稿, 搜索 */
#page-video .col-full {
  display: flex;

  >.main-content {
    flex: 1;

    .cube-list {
      width: initial !important;
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
    }
  }
}

/* 合集 */
.channel-index {
  width: 100% !important;
}

.feed-dynamic {
  flex: 1;
}`,
    search: `/* 搜索页 */
.i_wrapper {
  padding: 0 var(--layout-padding);
}`,
    read: `/* 阅读页 */
.article-detail {
  width: 90%;

  .article-up-info {
    width: initial;
    margin: 0 80px 20px;
  }
  .right-side-bar {
    right: 0;
  }
}`,
    video: `/* 播放器 */
:root {
  --nav-height: 64px;
  --video-height: calc(100vh - var(--nav-height));
}

.video-container-v1,      /* 视频页 */
.left-container,
.main-container,          /* 番剧页 */
.playlist-container--left /* 收藏/稍后再看页 */
{
  position: initial !important;
}

#playerWrap,
#bilibili-player-wrap {
  position: absolute;
  left: 0;
  right: 0;
  top: var(--nav-height);
  height: var(--video-height);
  padding-right: 0 !important; /* 番剧页加载时会有右填充 */
}

div#bilibili-player {
  width: 100%;
  height: 100%;
  box-shadow: none !important;
}

/* 修复加载动画不显示 */
.bpx-player-loading-panel-blur {
  display: flex !important;
}

/* 原弹幕发送区域不显示 */
.bpx-player-sending-area {
  display: none;
}

/* 导航栏 */
#biliMainHeader {
  margin-bottom: var(--video-height);
  position: sticky;
  top: 0;
  z-index: 3;
}

.bili-header.fixed-header {
  min-height: 0 !important;
}

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

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

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

.plp-r {
  position: sticky !important; /* 番剧页加载时不会先使用sticky */
}

/* 番剧/影视页下方容器 */
.main-container {
  width: 100%;
  margin: 0;
  padding-top: 15px;
  padding-left: var(--layout-padding);
  padding-right: var(--layout-padding);
  box-sizing: border-box;
  display: flex;
}

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

.toolbar {
  padding-top: 0;
}

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

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

/* bgm浮窗 */
#bgm-entry {
  z-index: 114514 !important;
  left: 0 !important;
}

/* 笔记浮窗 */
.note-pc {
  z-index: 114514 !important;
}

/* 番剧页右下方浮动按钮修正 */
div[class^=navTools_floatNav] {
  z-index: 2 !important;
}

/* Bilibili Evolved侧栏 */
.be-settings .sidebar {
  z-index: 114514 !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;
}`,
    controls: `/* 播放器控件 */
.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;
}

.bpx-player-ctrl-btn {
  width: auto !important;
  margin: 0 !important;
}

.bpx-player-ctrl-time-seek {
  width: 100% !important;
  padding: 0 !important;
  left: 0 !important;
}

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

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

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

  >div {
    padding: 0 !important;
  }
}

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

.bpx-player-video-inputbar {
  min-width: initial !important;
}`,
    mini: `/* 小窗 */
.bpx-player-container[data-screen="mini"] {
  /* 以视频长宽比为准,不显示黑边和阴影 */
  height: auto !important;
  box-shadow: none;
  translate: 32px 40px; /* 修正小窗位置 */
}
/* 非小窗不使用自定义宽度 */
.bpx-player-container[data-screen="web"] {
  width: 100% !important;
}

/* 最小宽度,以防不可见 */
.bpx-player-container {
  min-width: 180px;
}

.bpx-player-mini-resizer {
  position: absolute;
  left: 0;
  width: 10px;
  height: 100%;
  cursor: ew-resize;
}`,
    lowerNavigation: `/* 导航栏下置 */
#biliMainHeader {
  margin-top: 100vh;
  margin-bottom: 0;
}

#playerWrap,
#bilibili-player-wrap {
  top: 0;
  height: 100vh;
}`
  }
  GM_addStyle(styles.common)

  /**
   * @typedef {Object} Option
   * @property {string} name
   * @property {boolean|number} default
   */
  const /** @type {Option[]} */ options = [
    {
      name: '导航栏下置',
      default: true
    },
    {
      name: '播放器控件样式',
      default: true
    },
    {
      name: '左右边距',
      default: 30
    }
  ]

  /** @param {Option} option */
  function optionInput (option) {
    switch (typeof option.default) {
      case 'boolean':
        return `<input type="checkbox" ${GM_getValue(option.name, option.default) ? ' checked' : ''}>`
      case 'number':
        return `<input type="number" min="0" value="${GM_getValue(option.name, option.default)}">`
    }
  }

  // 设置选项功能
  const optionsDiv = document.body.appendChild(document.createElement('div'))
  optionsDiv.id = 'WBOptions'
  optionsDiv.innerHTML = `<button id="WBOptionsClose">×</button>
<header>⚙️宽屏选项</header>
${options.map(option => `<label>${optionInput(option)}${option.name}</label>`).join('\n')}`

  // 调出设置选项
  GM_registerMenuCommand('选项', () => { optionsDiv.style.display = 'grid' })
  // 关闭设置选项
  document.getElementById('WBOptionsClose')?.addEventListener('click', () => { optionsDiv.style.display = 'none' })

  // 设置选项事件
  for (const input of optionsDiv.getElementsByTagName('input')) {
    const key = input.parentElement?.textContent ?? ''
    if (!key) { continue }
    switch (input.type) {
      case 'checkbox':
        input.onchange = () => { GM_setValue(key, input.checked) }
        break
      case 'number':
        input.oninput = () => {
          const val = Number(input.value)
          Number.isInteger(val) && GM_setValue(key, val)
        }
        break
    }
  }

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

  /**
   * 等待条件满足并返回结果
   * @template T
   * @param {() => T} loaded
   * @returns {Promise<NonNullable<T>>}
   * @description 每一定时间检测某个条件是否满足,超时则reject
   */
  const waitFor = (loaded, desc = '页面加载', retry = 100, interval = 100) => new Promise((resolve, reject) => {
    const intervalID = setInterval((res = loaded()) => {
      if (res) {
        clearInterval(intervalID)
        console.log(`${desc}已加载`)
        return resolve(res)
      }
      if (--retry === 0) {
        console.error('页面加载超时')
        clearInterval(intervalID)
        return reject(new Error('timeout'))
      }
      if (retry % 10 === 0) { console.debug(`等待${desc}`) }
    }, interval)
  })

  /**
   * 直接获取元素或等待元素被添加
   * @param {string} className
   * @param {Element} [parent]
   * @returns {Promise<Element>}
   */
  const observeFor = (className, parent = document.body) => new Promise(resolve => {
    const elem = parent.getElementsByClassName(className)[0]
    if (elem) { return resolve(elem) }
    new MutationObserver((mutations, observer) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node instanceof Element && node.classList.contains(className)) {
            observer.disconnect()
            return resolve(node)
          }
        }
      }
    }).observe(parent, { childList: true })
  })

  switch ((new URL(window.location.href)).host) {
    case 't.bilibili.com':
      GM_addStyle(styles.t)
      waitFor(() => document.getElementsByClassName('right')[0], '动态右栏').then(right => {
        right.prepend(...document.getElementsByClassName('left')[0]?.childNodes ?? [])
      })
      console.info('使用动态样式')
      break
    case 'space.bilibili.com':
      GM_addStyle(styles.space)
      console.info('使用空间样式')
      break
    case 'search.bilibili.com':
      GM_addStyle(styles.search)
      console.info('使用搜索页样式')
      break
    case 'www.bilibili.com': {
      if (document.getElementById('i_cecream')) { // 首页
        GM_addStyle(styles.home)
        console.info('使用首页宽屏样式')
        break
      }

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

      // #region 视频页
      const player = document.getElementById('bilibili-player')
      if (!player) { return console.info('未找到播放器,仅启用通用样式') }
      // 播放器外容器,视频播放页为#playerWrap,番剧/影视播放页为#bilibili-player-wrap
      const stylesheets = {
        video: GM_addStyle(styles.video),
        controls: GM_addStyle(styles.controls),
        mini: GM_addStyle(styles.mini),
        lowerNavigation: GM_addStyle(styles.lowerNavigation)
      }

      const header = document.getElementById('biliMainHeader')
      if (!header) { return console.error('页面加载错误:未找到导航栏') }
      // 改变导航栏位置至下方
      const lowerNavigation = (value = true) => { stylesheets.lowerNavigation.disabled = !value }
      lowerNavigation(GM_getValue('导航栏下置'))
      GM_addValueChangeListener('导航栏下置', (_k, _o, newVal) => { lowerNavigation(newVal) })

      const styledControls = (value = true) => { stylesheets.controls.disabled = !value }
      styledControls(GM_getValue('播放器控件样式'))
      GM_addValueChangeListener('播放器控件样式', (_k, _o, newVal) => { styledControls(newVal) })

      // 等待人数加载完成,再进行dom操作
      const infos = player.getElementsByClassName('bpx-player-video-info')
      await waitFor(() => infos[0], '正在观看')

      // 播放器内容器
      const container = player.getElementsByClassName('bpx-player-container')[0]
      if (!(container instanceof HTMLDivElement)) { return console.error('页面加载错误:播放器内容器不存在') }
      // 播放器底中部框 (用于放置弹幕框内容)
      const bottomCenter = container.getElementsByClassName('bpx-player-control-bottom-center')[0]
      const center = bottomCenter?.previousElementSibling?.hasChildNodes()
        ? bottomCenter
        : player.getElementsByClassName('squirtle-controller-wrap-center')[0]
      // 原弹幕框
      const danmaku = container.getElementsByClassName('bpx-player-sending-bar')[0]
      if (!center || !danmaku) { return console.error('页面加载错误:弹幕框不存在') }

      // 立即使用宽屏样式 (除非当前是小窗模式)
      if (container.getAttribute('data-screen') !== 'mini') {
        container.setAttribute('data-screen', 'web')
      }
      // 重载container的setAttribute:data-screen被设置为mini(小窗)以外的值时将其设置为web(宽屏)
      container.setAttribute = new Proxy(container.setAttribute, {
        apply: (target, thisArg, /** @type {[string, string]} */ [name, val]) =>
          target.apply(thisArg, [name, name === 'data-screen' && val !== 'mini' ? 'web' : val])
      })
      // 移除原 宽屏/网页全屏 按钮,因为没有用了
      for (const className of [
        'bpx-player-ctrl-wide', 'bpx-player-ctrl-web',
        'squirtle-widescreen-wrap', 'squirtle-pagefullscreen-wrap'
      ]) { player.getElementsByClassName(className)[0]?.remove() }

      // 退出全屏时弹幕框移至播放器下方
      document.addEventListener('fullscreenchange', () => {
        if (!document.fullscreenElement) { center.replaceChildren(danmaku) }
      })
      // 立即将弹幕框移至播放器下方一次
      center.replaceChildren(danmaku)

      // 将自定义顶栏插入默认顶栏后
      observeFor('custom-navbar').then(nav => header.insertAdjacentElement('afterend', nav))

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

      // 添加拖动调整大小的部件
      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 })
      }

      const videoArea = container.getElementsByClassName('bpx-player-video-area')[0]
      videoArea && observeFor('bpx-player-mini-warp', videoArea).then(wrap => wrap.appendChild(miniResizer))
      // #endregion
      break
    }
    default:
      console.info('未知页面,仅启用通用样式')
      break
  }
})()