哔哩哔哩(B站, bilibili)播放界面和部分操作优化

B站播放器速度自定义(0.25 ~ 3), 支持快捷键(z:正常, x:减少速度, c:增加速度), 鼠标中键切换全屏等

// ==UserScript==
// @name         哔哩哔哩(B站, bilibili)播放界面和部分操作优化
// @description  B站播放器速度自定义(0.25 ~ 3), 支持快捷键(z:正常, x:减少速度, c:增加速度), 鼠标中键切换全屏等
// @namespace    bili
// @version      1.6.5
// @author       vizo
// @require      https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js
// @include      *://www.bilibili.com/video*
// @include      *://www.bilibili.com/bangumi*
// @include      *://www.bilibili.com/medialist/play*
// @include      https://search.bilibili.com*
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-end
// @noframes
// ==/UserScript==

GM_addStyle(`
  html {
    overflow-y: scroll;
  }
  html.hideScroll {
    overflow: hidden;
  }
  body::-webkit-scrollbar {
    width: 3px;
  }
  body::-webkit-scrollbar-corner,
  body::-webkit-scrollbar-track {
    background-color: #f8f8f8;
  }
  body::-webkit-scrollbar-thumb {
    background: #c5c5c5;
  }
  
  #bilibili-player {
    position: relative;
  }
  #spsy_msg {
    width: 105px;
    height: 42px;
    text-align: center;
    line-height: 42px;
    border-radius: 4px;
    background: rgba(255,255,255,.8);
    color: #222;
    font-size: 16px;
    position: absolute;
    top: -80px;
    right: 0;
    bottom: 0;
    left: 0;
    margin: auto;
    z-index: 99999;
    pointer-events: none;
    display: none;
  }
  #bl_info_xz {
    height: 25px;
    line-height: 25px;
    font-size: 14px;
    padding: 0 5px;
    color: #00a1d6;
    position: absolute;
    top: -25px;
    right: 0;
    z-index: 2;
  }
  .bilibili-player-video-btn-volume,
  .bilibili-player-video-btn-speed {
    opacity: 0.4;
    pointer-events: none;
  }
  .bilibili-player-volumeHint {
    display: none !important;
  }
  .so-fg5r {
    
  }
  .so-fg5r .inp {
    width: 50px;
    color: #666;
    border: 1px solid #0ad;
    border-radius: 2px;
    padding: 2px 5px;
    outline: none;
  }
  .so-fg5r .inp:focus {
    border-color: #0ad;
  }
  .so-fg5r span {
    color: #666;
  }
  .video-item.hide_7s {
    display: none !important;
  }
`)


const G = {
  timer1s: 0,
  timer2s: 0,
  timer3s: 0,
  focusTime: 0,
  g2URL: '',
}

const R = {
  timeout(ms = 0) {
    return new Promise(resolve => setTimeout(resolve, ms))
  },
  round(val, n = 2) {
    return Number(`${Math.round(`${val}e${n}`)}e-${n}`)
  },
  floor(val, n = 2) {
    return Number(`${Math.floor(`${val}e${n}`)}e-${n}`)
  },
  loadEl(fnz, timeout = 30e3) {
    return new Promise(resolve => {
      const iFn = () => {
        let el = typeof fnz === 'function' ? fnz() : $(fnz)
        if (el.length) {
          return resolve(el)
        }
        setTimeout(iFn, 50)
      }
      iFn()
      setTimeout(() => {
        return resolve($(null))
      }, timeout)
    })
  },
  watchDom(el, ...args) {
    if (typeof el === 'string') {
      el = document.querySelector(el)
    }
    let opt = args.length > 1 ? args[0] : {}
    let cb = args.length > 1 ? args[1] : args[0]
    return new MutationObserver((mutations, observer) => {
      cb(mutations)
    }).observe(el, Object.assign({
      childList: true,
      attributes: true,
      characterData: true,
      subtree: true,
    }, opt))
  },
  async getVideoWrap() {
    return (await R.loadEl('#bilibiliPlayer'))[0]
  },
  async getVideo() {
    return (await R.loadEl(`#${(await R.getVideoWrap()).id} video`))[0]
  },
  async appendMsgLay() {
    let wp = await R.getVideoWrap()
    let msg = document.getElementById('spsy_msg')
    if (!wp.contains(msg)) {
      wp.insertAdjacentHTML('beforeend', `<div id="spsy_msg"></div>`)
    }
  },
  async appendAxInfo() {
    let wp = await R.getVideoWrap()
    let inf = document.getElementById('bl_info_xz')
    if (!wp.contains(inf)) {
      wp.insertAdjacentHTML('beforeend', `<div id="bl_info_xz"></div>`)
    }
  },
  setAxInfo() {
    let inf = $('#bl_info_xz')
    let vol = Math.trunc(R.getGMvolume() * 100)
    let speed = R.getGMspeed()
    if (inf.length) {
      speed = speed === 1 ? speed : `<span style="color:#f33;">${speed}</span>`
      inf.html(`<span>速度: ${speed} &nbsp; 音量: ${vol}</span>`)
    }
  },
  toggleVideoFullscreen() {
    $('.bilibili-player-video-btn-fullscreen')[0].click()
  },
  setGMspeed(val) {
    return GM_setValue('--> bl_player_speed', val)
  },
  setGMvolume(val) {
    GM_setValue('--> bl_player_volume', val)
  },
  getGMspeed() {
    return +GM_getValue('--> bl_player_speed') || 1
  },
  getGMvolume() {
    let vol = GM_getValue('--> bl_player_volume')
    return vol !== undefined ? vol : 0.5
  },
  // 判断是否全屏
  isFullScreen() {
    return document.isFullScreen || document.mozIsFullScreen || document.webkitIsFullScreen
  },
  // 显示信息
  showSpMsg(msg, type = '速度') {
    let mp = $('#spsy_msg')
    clearTimeout(G.timer2s)
    mp.fadeIn(180)
    mp.text(`${type} ${msg}`)
    G.timer2s = setTimeout(() => {
      mp.fadeOut(350)
    }, 800)
  },
  // 设置播放器播放速度
  async setPlayerSpeed(speedVal = R.getGMspeed()) {
    let video = await R.getVideo()
    video.playbackRate = speedVal
    R.setGMspeed(speedVal)
    R.setAxInfo()
  },
  // 设置音量
  async setVolume(vol = R.getGMvolume()) {
    let video = await R.getVideo()
    video.volume = vol
    R.setGMvolume(vol)
    R.setAxInfo()
  },
  
  initVideoCfg() {
    clearTimeout(G.timer3s)
    G.timer3s = setTimeout(() => {
      R.appendMsgLay()
      R.appendAxInfo()
      R.setPlayerSpeed()
      R.setVolume()
    }, 10)
  },
  
  debounce(func, delay) {
    let timer = null
    return function() {
      timer && clearTimeout(timer)
      timer = setTimeout(() => {
        func.apply(this, arguments)
      }, delay)
    }
  },
  
  zTimer() {
    R.initVideoCfg()
    if (document.hasFocus()) {
      G.focusTime = Date.now()
    }
    setTimeout(R.zTimer, 2000)
  },
  
  async regWpMouseEvt() {
    const wwp = $('#playerWrap')
    const wp = wwp.length ? wwp : await R.getVideoWrap()
    $(wp).on('mouseenter', () => {
      $('html').addClass('hideScroll')
    })
    $(wp).on('mouseleave', () => {
      $('html').removeClass('hideScroll')
    })
  },
  
  watchUrlFunc() {
    if (G.g2URL !== location.href) {
      G.g2URL = location.href
      R.eachSearchResultAndAddHideCls()
    }
    setTimeout(R.watchUrlFunc, 500)
  },
  
  judgeIsAppendSoMod() {
    const html = `
      <div class="so-fg5r">
        <input class="inp inp-s" type="text">
        <span> - </span>
        <input class="inp inp-e" type="text">
        <span>分钟</span>
      </div>
    `
    $('ul.filter-type.duration').append(html)
  },
  
  eachSearchResultAndAddHideCls() {
    const val1 = $('.inp-s').val()
    const val2 = $('.inp-e').val()
    if (!val1 || !val2) {
      $('.video-list > li.video-item').each((i, v) => {
        $(v).removeClass('hide_7s')
      })
      return
    }
    
    $('.video-list > li.video-item').each((i, v) => {
      let tis = $(v)
      tis.removeClass('hide_7s')
      let sTime = tis.find('.so-imgTag_rb').text()
      let mth = sTime.match(/\d{2}(?=(\:\d{2}){2})/)
      let hour = mth ? mth[0] : 0
      let minute = sTime.replace(/(?:\d{2}\:)?(\d{2})\:\d{2}/, '$1')
      let total = Number(hour) * 60 + Number(minute)
      
      if (total < Number(val1) || total > Number(val2)) {
        tis.closest('.video-item').addClass('hide_7s')
      }
    })
  },
  
}
  
  // 滚轮中键点击(滚轮点击)切换全屏
  $('body').on('mousedown', '#bilibili-player', function(e) {
    if (e.button === 1) {
      e.preventDefault()
      R.toggleVideoFullscreen()
    }
  })
  
  // 筛选搜索结果
  $('body').on('change', '.inp-s, .inp-e', function() {
    R.eachSearchResultAndAddHideCls()
  })
  
  // ctrl和alt按下后短时间内阻止zxc
  $('body').on('keydown', function(e) {
    if (/up|down/i.test(e.key)) {
      e.stopPropagation()
      e.preventDefault()
    }
  })
  
  // 键盘快捷键
  $('body').on('keyup', async function(e) {
    if (e.target.nodeName !== 'BODY') return
    
    if (/^[zxc]$/.test(e.key) && Date.now() - G.focusTime <= 3000) {
      let val = R.getGMspeed()
      if (e.key === 'z') {
        val = 1
      }
      if (e.key === 'x') {
        val = Math.max(val - 0.25, 0.25)
      }
      if (e.key === 'c') {
        val = Math.min(val + 0.25, 3)
      }
      R.setPlayerSpeed(val)
      R.showSpMsg(val)
    }
    
    if (/up|down/i.test(e.key)) {
      let vl = R.getGMvolume()
      let vol = e.key.includes('Up') ? Math.min(vl + 0.03, 1) : Math.max(vl - 0.03, 0)
      R.setVolume(R.floor(vol))
      R.showSpMsg(Math.trunc(vol * 100), '音量')
    }
    
  })
  
  // 滚轮调节音量
  window.addEventListener('wheel', async (e) => {
    const wp = await R.getVideoWrap()
    const isContains = wp.contains(e.target)
    
    if (!isContains) return
    
    let vol = R.getGMvolume()
    
    if (e.deltaY > 0) {
      // 减少音量
      vol = Math.max(vol - (e.altKey ? 0.1 : 0.03), 0)
    } else {
      // 增加音量
      vol = Math.min(vol + (e.altKey ? 0.1 : 0.03), 1)
    }
    vol = R.floor(vol)
    R.setVolume(vol)
    
    let pVol = Math.trunc(vol * 100)
    R.showSpMsg(pVol, '音量')
  })
  
  const initFunc = async () => {
    if (location.host.includes('search.bilibili.com')) {
      R.judgeIsAppendSoMod()
      R.watchUrlFunc()
    } else {
      let video = await R.getVideo()
      R.zTimer()
      video.addEventListener('play', () => {
        R.initVideoCfg()
        R.regWpMouseEvt()
      })
      R.regWpMouseEvt()
    }
  }
  
  initFunc()