Bilibili 修车插件

允许您使用 B 站查看本地视频,支持上传弹幕,实时调整弹幕时间

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Bilibili 修车插件
// @namespace    http://tampermonkey.net/
// @version      1.16
// @description  允许您使用 B 站查看本地视频,支持上传弹幕,实时调整弹幕时间
// @author       you
// @match        https://www.bilibili.com/video/*
// @match        https://www.bilibili.com/bangumi/*
// @require      https://cdn.bootcss.com/jquery/3.3.1/jquery.js
// @require      https://greatest.deepsurf.us/scripts/376248-abab/code/%E2%86%91%E2%86%92%E2%86%93%E2%86%90ABAB.js?version=661929
// @grant        none
// ==/UserScript==

(function() {
    window.danmaku = []       // 实时弹幕
    window.danmakuLocal = []  // 本地弹幕
    window.danmakuClone = []  // 默认弹幕

    var lastOffsetDate = 0
    var lastOffsetVal = 0  // 弹幕时间偏移
    var danmakuMode = 1    // 弹幕合并模式
    var isFrozen = false   // 冻结弹幕
    var freezeTimer = 0
    var textTimer = 0

    const DANMAKU_DEFAULT = 0
    const DANMAKU_REPLACE = 1
    const DANMAKU_MERGE = 2

    function getStarted() {
        !!window.HOOKED_DATA? main(): requestAnimationFrame(getStarted)
    }

    function main() {
        createUploadPlugin().then(bindUploadEvents)  // 创建插件
        getDanmaku()       // 获取弹幕内存的引用
        bindKbdEvents()    // 绑定键盘事件
    }

    getStarted()

    /* ************************************************************************* */

    function createUploadPlugin() {
        return new Promise(function(resolve, reject) {
            let pluginHTML = `  <div class="uploadMediaPlugin"><button id=mediaSwitcher class="mediaSwitcher">上传</button><div id=mediaUploaderWrapper class="mediaUploader-wrapper fold"><div class="mediaUploader"><input id=mediaInputer class="mediaInputer" type="file" multiple/><div class="cover">选择文件</div></div></div></div>`
            let pluginCSS = `.uploadMediaPlugin * {margin: 0;padding: 0;box-sizing: border-box;}.uploadMediaPlugin {display: flex;position: fixed;top: 28vh;z-index: 10;}.uploadMediaPlugin .mediaSwitcher {z-index: 11;border: none;outline: none;width: 30px;height: 40px;padding: 0 5px;background: skyblue;color: white;text-align: center;line-height: 1.2;font-size: 12px;cursor: pointer;}.uploadMediaPlugin .mediaSwitcher:hover {background: #00b4e5;}.uploadMediaPlugin .mediaUploader-wrapper {transform: translateX(0);transition: all .3s;}.uploadMediaPlugin .mediaUploader-wrapper.fold {transform: translateX(-100px);}.uploadMediaPlugin .mediaUploader {position: relative;width: 70px;height: 40px;}.uploadMediaPlugin .mediaInputer {opacity: 0;position: absolute;width: 100%;height: 100%;}.uploadMediaPlugin .cover {position: absolute;width: 100%;height: 100%;background: #eee;color: black;text-align: center;line-height: 40px;font-size: 12px;pointer-events: none;}`
            addHTML(pluginHTML, 'body')
            addCSS(pluginCSS)
            resolve()
        })
    }

    function bindUploadEvents() {
        $(mediaSwitcher).on('click', switchState)
        $(mediaInputer).on('click', clearMedia).on('change', setMedia)
    }

    function getDanmaku() {
        return new Promise(function(resolve, reject) {
            if (window.HOOKED_DATA && HOOKED_DATA.g && HOOKED_DATA.g.g && HOOKED_DATA.g.g.xd && HOOKED_DATA.g.g.xd.length) {
                danmaku = HOOKED_DATA.g.g.xd
                danmakuClone = JSON.parse(JSON.stringify(danmaku))
                resolve()
            } else {
                requestAnimationFrame(function() {
                    getDanmaku()
                })
            }
        })
    }

    function bindKbdEvents() {
        $(document).on('keydown', offsetDanmaku)  // 弹幕时间调整
        $(document).on('keydown', freezeDanmaku)  // 弹幕冻结
        $(document).on('keydown', shiftDanmakuMode)  // 弹幕模式切换
    }

    // XML 弹幕解析器
    function readFile(file) {
        var reader = new FileReader()
        reader.onload = function() {
            danmakuLocal = danmakuReader(this.result)
        }
        reader.readAsText(file)
    }

    function danmakuReader(str) {
        let danmakuList = str.match(/<d.+?<\/d>/g)
        let hashArr = []

        danmakuList.map(d=>{
            let info = d.match(/p="(.+?)">(.+?)<\/d>/)
            let parts = info[1].split(',')
            let hash = {}

            hash['border'] = false
            hash['borderColor'] = 6750207
            hash['class'] = parseInt(parts[5])
            hash['color'] = parseInt(parts[3])
            hash['date'] = parseInt(parts[4])
            hash['dmid'] = parseFloat(parts[7])
            hash['eb'] = parseInt(parts[5])
            hash['mode'] = parseInt(parts[1])
            hash['on'] = false
            hash['size'] = parseInt(parts[2])
            hash['stime'] = parseFloat(parts[0])
            hash['text'] = info[2]
            hash['uid'] = parts[6]

            hashArr.push(hash)
        }
        )

        return hashArr.sort((x,y)=>x.stime - y.stime)
    }

    // 弹幕模式切换 (替换,合并,默认)
    function danmakuInject(mode=DANMAKU_DEFAULT, isHint=true) {
        if (isHint && danmakuLocal && !danmakuLocal.length) {
            return printMsg('未发现本地弹幕')
        }else if(!isHint) {
            return printMsg('正在导入本地弹幕...')
        } else {
        	printMsg(['默认弹幕', '替换弹幕', '合并弹幕'][danmakuMode])
        }

        switch (mode) {
        case DANMAKU_REPLACE:
            {
                danmaku.length = 0
                danmakuLocal.map(d=>danmaku.push(d))
                break
            };
        case DANMAKU_MERGE:
            {
                danmaku.length = 0
                danmakuLocal.concat(danmakuClone).map(d=>danmaku.push(d))
                danmaku = danmaku.sort((x,y)=>x.stime - y.stime)
                break
            };
        case DANMAKU_DEFAULT:
            {
                danmaku.length = 0
                danmakuClone.map(d=>danmaku.push(d))
                break
            };
        }
    }

    // 弹幕时间轴调整(秒为单位)
    function danmakuOffset(t) {
        danmaku.map(s=>s.stime += (t - lastOffsetVal))
        lastOffsetVal = t
    }

    function offsetDanmaku(e) {
        if (+new Date - lastOffsetDate < 100) return  // 触发频率控制
        lastOffsetDate = +new Date

        if (e.keyCode === 188) {
            danmakuOffset(lastOffsetVal - 1)
            printMsg(`弹幕延时: ${lastOffsetVal} s`)
        } else if (e.keyCode === 190) {
            danmakuOffset(lastOffsetVal + 1)
            printMsg(`弹幕延时: ${lastOffsetVal} s`)
        }
    }

    function freezeDanmaku(e) {
        if (e.keyCode === 191) {
            isFrozen = !isFrozen
            if (isFrozen) {
                printMsg('弹幕冻结')
                $('.bilibili-player-video-danmaku').hide()
                // $('.bilibili-player-video-adv-danmaku').hide()
                freezeTimer = setInterval(()=>danmakuOffset(lastOffsetVal + 1), 1000)
            } else {
                printMsg(`弹幕解冻,延时 ${lastOffsetVal} s`)
                $('.bilibili-player-video-danmaku').show().children().each(function() {
                    $(this).hide()
                })
                // $('.bilibili-player-video-adv-danmaku').show().children().each(function() {$(this).hide()})
                clearInterval(freezeTimer)
            }
        }
    }

    function shiftDanmakuMode(e) {
        if (e.keyCode === 16) {
            danmakuMode = (danmakuMode + 1) % 3
            danmakuInject(danmakuMode)
        }
    }

    function formatTime(time) {
        if (null === time || '' === time || isNaN(time) || undefined === time)
            return

        var timeString = (new Date(16 * 3600 * 1000 + Math.abs(time) * 1000) + '')
        var reg = time >= 3600000 ? /\d\d:\d\d:\d\d/ : /\d\d:\d\d /

        timeString = timeString.match(reg)[0]
        if (timeString < 0)
            timeString = `-${timeString}`

        return timeString
    }

    function addHTML(html, selector) {
        let div = document.createElement('div')
        div.innerHTML = html
        let parent = document.querySelector(selector)
        let children = [...div.children]
        children.map(child=>parent.appendChild(child))
    }

    function addCSS(css) {
        var style = document.createElement('style')
        style.innerHTML = css
        document.head.appendChild(style)
    }

    function switchState(e) {

        $(this).siblings().toggleClass('fold')
    }

    function clearMedia(e) {
        e.target.value = null
    }

    function setMedia(e) {
        let files = this.files
        if (!files.length)
            return

        $(mediaUploaderWrapper).addClass('fold')

        let videoFiles = [...files].filter(f=> {
        	return /^video/.test(f.type) || /[Mm][Kk][Vv]$/.test(f.name)
        })

        let danmakuFiles = [...files].filter(f=>/[Xx][Mm][Ll]$/.test(f.name))

        if (videoFiles.length > 0) {
            setMediaVideo(videoFiles[0])
            console.log(`发现视频 ${videoFiles[0].name}`)
        }

        if (danmakuFiles.length > 0) {
            setMediaDanmaku(danmakuFiles[0])
            console.log(`发现弹幕 ${danmakuFiles[0].name}`)
        }
    }

    function setMediaVideo(file) {
        $('video')[0].pause()
        $('video').attr('src', window.URL.createObjectURL(file))
        alert(`${file.name} 上传成功`)
        fixProgress()
        setTimeout(()=>{
            $('video').attr('src', window.URL.createObjectURL(file))
            $('video')[0].play()
        }, 100)
    }

    function setMediaDanmaku(file) {
        $('.bilibili-player-video-adv-danmaku').hide() // 隐藏高级弹幕

        readFile(file)
        danmakuInject(DANMAKU_REPLACE, false)
        setTimeout(()=> danmakuInject(DANMAKU_REPLACE), 1000)
    }

    function fixProgress() {
        let video = $('video')[0]

        $('.bilibili-player-video-progress').on('click', e=>{  // 修复鼠标事件
            let percent = parseFloat($('.bpui-slider-handle')[0].style.left) / 100
            video.currentTime = percent * video.duration
        })

        var durationText = formatTime(Math.floor(this.duration)) // 修改视频时长
        $('.bilibili-player-video-time-total').text(durationText)

        $('video').on('timeupdate', function() {  // 修复时间事件
            var currentTimeText = formatTime(Math.floor(this.currentTime))  // 修改当前时间
            $('.bilibili-player-video-time-now').text(currentTimeText)
            // var per = video.currentTime / video.duration                    // 修改进度条位置
            // $('.bpui-slider-handle')[0].style.left = `${100 * per }%`
            // $('.bpui-slider-progress')[0].style.width = `${100 * per}%`
        })
    }

	function printMsg(msg) {
	  clearTimeout(textTimer)
	  activeInformPanel(msg)
	  textTimer = setTimeout(deactiveInformPanel, 1500)
	}

	function activeInformPanel(msg) {
	  $('.bilibili-player-video-panel').css({'display': 'block', 'background-color': 'transparent', 'pointer-events': 'none'})
	  $('.bilibili-player-video-panel .bilibili-player-video-panel-image').hide()
	  $('.bilibili-player-video-panel [stage]').hide()
	  $('.bilibili-player-video-panel [stage=0]').css({'font-size': '18px', 'transform': 'translateY(-20px)'}).show().text(msg)
	}

	function deactiveInformPanel() {
	  $('.bilibili-player-video-panel').css({'display': 'none', 'background-color': 'white', 'pointer-events': 'inherit'})
	  $('.bilibili-player-video-panel .bilibili-player-video-panel-image').show()
	  $('.bilibili-player-video-panel [stage]').show()
	  $('.bilibili-player-video-panel [stage=0]').css({'font-size': '12px', 'transform': 'translateY(0)'}).text(`播放器初始化...[完成]`)
	}
}
)();