Greasy Fork is available in English.

Bilibili 修车插件

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

// ==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://greasyfork.org/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(`播放器初始化...[完成]`)
	}
}
)();