// ==UserScript== // @name bilibili merged flv+mp4+ass+enhance // @namespace http://qli5.tk/ // @homepageURL https://github.com/liqi0816/bilitwin/ // @description bilibili/哔哩哔哩:超清FLV下载,FLV合并,原生MP4下载,弹幕ASS下载,MKV打包,播放体验增强,原生appsecret,不借助其他网站 // @match *://www.bilibili.com/video/av* // @match *://bangumi.bilibili.com/anime/*/play* // @match *://www.bilibili.com/bangumi/play/ep* // @match *://www.bilibili.com/bangumi/play/ss* // @match *://www.bilibili.com/watchlater/ // @version 1.13 // @author qli5 // @copyright qli5, 2014+, 田生, grepmusic, zheng qian, ryiwamoto // @license Mozilla Public License 2.0; http://www.mozilla.org/MPL/2.0/ // @grant none // @downloadURL none // ==/UserScript== let debugOption = { // console会清空,生成 window.m 和 window.p //debug: 1, // 别拖啦~ //betabeta: 1, // UP主不容易,B站也不容易,充电是有益的尝试,我不鼓励跳。 //electricSkippable: 0, }; /** * @author qli5 * * BiliTwin consists of two parts - BiliMonkey and BiliPolyfill. * They are bundled because I am too lazy to write two user interfaces. * * So what is the difference between BiliMonkey and BiliPolyfill? * * BiliMonkey deals with network. It is a (naIve) Service Worker. * This is also why it uses IndexedDB instead of localStorage. * BiliPolyfill deals with experience. It is more a "user script". * Everything it can do can be done by hand. * * BiliPolyfill will be pointless in the long run - I believe bilibili * will finally provide these functions themselves. * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Covered Software is provided under this License on an “as is” basis, * without warranty of any kind, either expressed, implied, or statutory, * including, without limitation, warranties that the Covered Software * is free of defects, merchantable, fit for a particular purpose or * non-infringing. The entire risk as to the quality and performance of * the Covered Software is with You. Should any Covered Software prove * defective in any respect, You (not any Contributor) assume the cost * of any necessary servicing, repair, or correction. This disclaimer * of warranty constitutes an essential part of this License. No use of * any Covered Software is authorized under this License except under * this disclaimer. * * Under no circumstances and under no legal theory, whether tort * (including negligence), contract, or otherwise, shall any Contributor, * or anyone who distributes Covered Software as permitted above, be * liable to You for any direct, indirect, special, incidental, or * consequential damages of any character including, without limitation, * damages for lost profits, loss of goodwill, work stoppage, computer * failure or malfunction, or any and all other commercial damages or * losses, even if such party shall have been informed of the possibility * of such damages. This limitation of liability shall not apply to * liability for death or personal injury resulting from such party’s * negligence to the extent applicable law prohibits such limitation. * Some jurisdictions do not allow the exclusion or limitation of * incidental or consequential damages, so this exclusion and limitation * may not apply to You. */ /** * BiliMonkey * A bilibili user script * by qli5 goodlq11[at](gmail|163).com * * The FLV merge utility is a Javascript translation of * https://github.com/grepmusic/flvmerge * by grepmusic * * The ASS convert utility is a fork of * https://tiansh.github.io/us-danmaku/bilibili/ * by tiansh * * The FLV demuxer is from * https://github.com/Bilibili/flv.js/ * by zheng qian * * The EMBL builder is from * * by ryiwamoto * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * BiliPolyfill * A bilibili user script * by qli5 goodlq11[at](gmail|163).com * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ class TwentyFourDataView extends DataView { constructor(...args) { if (TwentyFourDataView.es6) { super(...args); } else { // ES5 polyfill // It is dirty. Very dirty. if (TwentyFourDataView.es6 === undefined) { try { TwentyFourDataView.es6 = 1; return super(...args); } catch (e) { if (e.name == 'TypeError') { TwentyFourDataView.es6 = 0; let setPrototypeOf = Object.setPrototypeOf || function (obj, proto) { obj.__proto__ = proto; return obj; }; setPrototypeOf(TwentyFourDataView, Object); } else throw e; } } super(); let _dataView = new DataView(...args); _dataView.getUint24 = TwentyFourDataView.prototype.getUint24; _dataView.setUint24 = TwentyFourDataView.prototype.setUint24; _dataView.indexOf = TwentyFourDataView.prototype.indexOf; return _dataView; } } getUint24(byteOffset, littleEndian) { if (littleEndian) throw 'littleEndian int24 not implemented'; return this.getUint32(byteOffset - 1) & 0x00FFFFFF; } setUint24(byteOffset, value, littleEndian) { if (littleEndian) throw 'littleEndian int24 not implemented'; if (value > 0x00FFFFFF) throw 'setUint24: number out of range'; let msb = value >> 16; let lsb = value & 0xFFFF; this.setUint8(byteOffset, msb); this.setUint16(byteOffset + 1, lsb); } indexOf(search, startOffset = 0, endOffset = this.byteLength - search.length + 1) { // I know it is NAIVE if (search.charCodeAt) { for (let i = startOffset; i < endOffset; i++) { if (this.getUint8(i) != search.charCodeAt(0)) continue; let found = 1; for (let j = 0; j < search.length; j++) { if (this.getUint8(i + j) != search.charCodeAt(j)) { found = 0; break; } } if (found) return i; } return -1; } else { for (let i = startOffset; i < endOffset; i++) { if (this.getUint8(i) != search[0]) continue; let found = 1; for (let j = 0; j < search.length; j++) { if (this.getUint8(i + j) != search[j]) { found = 0; break; } } if (found) return i; } return -1; } } } class FLVTag { constructor(dataView, currentOffset = 0) { this.tagHeader = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + currentOffset, 11); this.tagData = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + currentOffset + 11, this.dataSize); this.previousSize = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + currentOffset + 11 + this.dataSize, 4); } get tagType() { return this.tagHeader.getUint8(0); } get dataSize() { return this.tagHeader.getUint24(1); } get timestamp() { return this.tagHeader.getUint24(4); } get timestampExtension() { return this.tagHeader.getUint8(7); } get streamID() { return this.tagHeader.getUint24(8); } stripKeyframesScriptData() { let hasKeyframes = 'hasKeyframes\x01'; let keyframes = '\x00\x09keyframs\x03'; if (this.tagType != 0x12) throw 'can not strip non-scriptdata\'s keyframes'; let index; index = this.tagData.indexOf(hasKeyframes); if (index != -1) { //0x0101 => 0x0100 this.tagData.setUint8(index + hasKeyframes.length, 0x00); } // Well, I think it is unnecessary /*index = this.tagData.indexOf(keyframes) if (index != -1) { this.dataSize = index; this.tagHeader.setUint24(1, index); this.tagData = new TwentyFourDataView(this.tagData.buffer, this.tagData.byteOffset, index); }*/ } getDuration() { if (this.tagType != 0x12) throw 'can not find non-scriptdata\'s duration'; let duration = 'duration\x00'; let index = this.tagData.indexOf(duration); if (index == -1) throw 'can not get flv meta duration'; index += 9; return this.tagData.getFloat64(index); } getDurationAndView() { if (this.tagType != 0x12) throw 'can not find non-scriptdata\'s duration'; let duration = 'duration\x00'; let index = this.tagData.indexOf(duration); if (index == -1) throw 'can not get flv meta duration'; index += 9; return { duration: this.tagData.getFloat64(index), durationDataView: new TwentyFourDataView(this.tagData.buffer, this.tagData.byteOffset + index, 8) }; } getCombinedTimestamp() { return (this.timestampExtension << 24 | this.timestamp); } setCombinedTimestamp(timestamp) { if (timestamp < 0) throw 'timestamp < 0'; this.tagHeader.setUint8(7, timestamp >> 24); this.tagHeader.setUint24(4, timestamp & 0x00FFFFFF); } } class FLV { constructor(dataView) { if (dataView.indexOf('FLV', 0, 1) != 0) throw 'Invalid FLV header'; this.header = new TwentyFourDataView(dataView.buffer, dataView.byteOffset, 9); this.firstPreviousTagSize = new TwentyFourDataView(dataView.buffer, dataView.byteOffset + 9, 4); this.tags = []; let offset = this.headerLength + 4; while (offset < dataView.byteLength) { let tag = new FLVTag(dataView, offset); // debug for scrpit data tag // if (tag.tagType != 0x08 && tag.tagType != 0x09) offset += 11 + tag.dataSize + 4; this.tags.push(tag); } if (offset != dataView.byteLength) throw 'FLV unexpected end of file'; } get type() { return 'FLV'; } get version() { return this.header.getUint8(3); } get typeFlag() { return this.header.getUint8(4); } get headerLength() { return this.header.getUint32(5); } static merge(flvs) { if (flvs.length < 1) throw 'Usage: FLV.merge([flvs])'; let blobParts = []; let basetimestamp = [0, 0]; let lasttimestamp = [0, 0]; let duration = 0.0; let durationDataView; blobParts.push(flvs[0].header); blobParts.push(flvs[0].firstPreviousTagSize); for (let flv of flvs) { let bts = duration * 1000; basetimestamp[0] = lasttimestamp[0]; basetimestamp[1] = lasttimestamp[1]; bts = Math.max(bts, basetimestamp[0], basetimestamp[1]); let foundDuration = 0; for (let tag of flv.tags) { if (tag.tagType == 0x12 && !foundDuration) { duration += tag.getDuration(); foundDuration = 1; if (flv == flvs[0]) { ({ duration, durationDataView } = tag.getDurationAndView()); tag.stripKeyframesScriptData(); blobParts.push(tag.tagHeader); blobParts.push(tag.tagData); blobParts.push(tag.previousSize); } } else if (tag.tagType == 0x08 || tag.tagType == 0x09) { lasttimestamp[tag.tagType - 0x08] = bts + tag.getCombinedTimestamp(); tag.setCombinedTimestamp(lasttimestamp[tag.tagType - 0x08]); blobParts.push(tag.tagHeader); blobParts.push(tag.tagData); blobParts.push(tag.previousSize); } } } durationDataView.setFloat64(0, duration); return new Blob(blobParts); } static async mergeBlobs(blobs) { // Blobs can be swapped to disk, while Arraybuffers can not. // This is a RAM saving workaround. Somewhat. if (blobs.length < 1) throw 'Usage: FLV.mergeBlobs([blobs])'; let ret = []; let basetimestamp = [0, 0]; let lasttimestamp = [0, 0]; let duration = 0.0; let durationDataView; for (let blob of blobs) { let bts = duration * 1000; basetimestamp[0] = lasttimestamp[0]; basetimestamp[1] = lasttimestamp[1]; bts = Math.max(bts, basetimestamp[0], basetimestamp[1]); let foundDuration = 0; let flv = await new Promise((resolve, reject) => { let fr = new FileReader(); fr.onload = () => resolve(new FLV(new TwentyFourDataView(fr.result))); fr.readAsArrayBuffer(blob); fr.onerror = reject; }); let modifiedMediaTags = []; for (let tag of flv.tags) { if (tag.tagType == 0x12 && !foundDuration) { duration += tag.getDuration(); foundDuration = 1; if (blob == blobs[0]) { ret.push(flv.header, flv.firstPreviousTagSize); ({ duration, durationDataView } = tag.getDurationAndView()); tag.stripKeyframesScriptData(); ret.push(tag.tagHeader); ret.push(tag.tagData); ret.push(tag.previousSize); } } else if (tag.tagType == 0x08 || tag.tagType == 0x09) { lasttimestamp[tag.tagType - 0x08] = bts + tag.getCombinedTimestamp(); tag.setCombinedTimestamp(lasttimestamp[tag.tagType - 0x08]); modifiedMediaTags.push(tag.tagHeader, tag.tagData, tag.previousSize); } } ret.push(new Blob(modifiedMediaTags)); } durationDataView.setFloat64(0, duration); return new Blob(ret); } } class CacheDB { constructor(dbName = 'biliMonkey', osName = 'flv', keyPath = 'name', maxItemSize = 100 * 1024 * 1024) { this.dbName = dbName; this.osName = osName; this.keyPath = keyPath; this.maxItemSize = maxItemSize; this.db = null; } async getDB() { if (this.db) return this.db; this.db = new Promise((resolve, reject) => { let openRequest = indexedDB.open(this.dbName); openRequest.onupgradeneeded = e => { let db = e.target.result; if (!db.objectStoreNames.contains(this.osName)) { db.createObjectStore(this.osName, { keyPath: this.keyPath }); } } openRequest.onsuccess = e => { resolve(this.db = e.target.result); } openRequest.onerror = reject; }); return this.db; } async addData(item, name = item.name, data = item.data) { if (!data instanceof Blob) throw 'CacheDB: data must be a Blob'; let db = await this.getDB(); let itemChunks = []; let numChunks = Math.ceil(data.size / this.maxItemSize); for (let i = 0; i < numChunks; i++) { itemChunks.push({ name: `${name}_part_${i}`, numChunks, data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize) }); } let reqArr = []; for (let chunk of itemChunks) { reqArr.push(new Promise((resolve, reject) => { let req = db .transaction([this.osName], 'readwrite') .objectStore(this.osName) .add(chunk); req.onsuccess = resolve; req.onerror = reject; })); } return Promise.all(reqArr); } async putData(item, name = item.name, data = item.data) { if (!data instanceof Blob) throw 'CacheDB: data must be a Blob'; let db = await this.getDB(); let itemChunks = []; let numChunks = Math.ceil(data.size / this.maxItemSize); for (let i = 0; i < numChunks; i++) { itemChunks.push({ name: `${name}_part_${i}`, numChunks, data: data.slice(i * this.maxItemSize, (i + 1) * this.maxItemSize) }); } let reqArr = []; for (let chunk of itemChunks) { reqArr.push(new Promise((resolve, reject) => { let req = db .transaction([this.osName], 'readwrite') .objectStore(this.osName) .put(chunk); req.onsuccess = resolve; req.onerror = reject; })); } return Promise.all(reqArr); } async getData(index) { let db = await this.getDB(); let item_0 = await new Promise((resolve, reject) => { let req = db .transaction([this.osName]) .objectStore(this.osName) .get(`${index}_part_0`); req.onsuccess = () => resolve(req.result); req.onerror = reject; }); if (!item_0) return undefined; let { numChunks, data: data_0 } = item_0; let reqArr = [Promise.resolve(data_0)]; for (let i = 1; i < numChunks; i++) { reqArr.push(new Promise((resolve, reject) => { let req = db .transaction([this.osName]) .objectStore(this.osName) .get(`${index}_part_${i}`); req.onsuccess = () => resolve(req.result.data); req.onerror = reject; })); } let itemChunks = await Promise.all(reqArr); return { name: index, data: new Blob(itemChunks) }; } async deleteData(index) { let db = await this.getDB(); let item_0 = await new Promise((resolve, reject) => { let req = db .transaction([this.osName]) .objectStore(this.osName) .get(`${index}_part_0`); req.onsuccess = () => resolve(req.result); req.onerror = reject; }); if (!item_0) return undefined; let numChunks = item_0.numChunks; let reqArr = []; for (let i = 0; i < numChunks; i++) { reqArr.push(new Promise((resolve, reject) => { let req = db .transaction([this.osName], 'readwrite') .objectStore(this.osName) .delete(`${index}_part_${i}`); req.onsuccess = resolve; req.onerror = reject; })); } return Promise.all(reqArr); } async deleteEntireDB() { let req = indexedDB.deleteDatabase(this.dbName); return new Promise((resolve, reject) => { req.onsuccess = () => resolve(this.db = null); req.onerror = reject; }); } } class DetailedFetchBlob { constructor(input, init = {}, onprogress = init.onprogress, onabort = init.onabort, onerror = init.onerror, fetch = init.fetch || top.fetch) { // Fire in the Fox fix if (this.firefoxConstructor(input, init, onprogress, onabort, onerror)) return; // Now I know why standardizing cancelable Promise is that difficult // PLEASE refactor me! this.onprogress = onprogress; this.onabort = onabort; this.onerror = onerror; this.abort = null; this.loaded = init.cacheLoaded || 0; this.total = init.cacheLoaded || 0; this.lengthComputable = false; this.buffer = []; this.blob = null; this.reader = null; this.blobPromise = fetch(input, init).then(res => { if (this.reader == 'abort') return res.body.getReader().cancel().then(() => null); if (!res.ok) throw `HTTP Error ${res.status}: ${res.statusText}`; this.lengthComputable = res.headers.has('Content-Length'); this.total += parseInt(res.headers.get('Content-Length')) || Infinity; if (this.lengthComputable) { this.reader = res.body.getReader(); return this.blob = this.consume(); } else { if (this.onprogress) this.onprogress(this.loaded, this.total, this.lengthComputable); return this.blob = res.blob(); } }); this.blobPromise.then(() => this.abort = () => { }); this.blobPromise.catch(e => this.onerror({ target: this, type: e })); this.promise = Promise.race([ this.blobPromise, new Promise(resolve => this.abort = () => { this.onabort({ target: this, type: 'abort' }); resolve('abort'); this.buffer = []; this.blob = null; if (this.reader) this.reader.cancel(); else this.reader = 'abort'; }) ]).then(s => s == 'abort' ? new Promise(() => { }) : s); this.then = this.promise.then.bind(this.promise); this.catch = this.promise.catch.bind(this.promise); } getPartialBlob() { return new Blob(this.buffer); } async getBlob() { return this.promise; } async pump() { while (true) { let { done, value } = await this.reader.read(); if (done) return this.loaded; this.loaded += value.byteLength; this.buffer.push(new Blob([value])); if (this.onprogress) this.onprogress(this.loaded, this.total, this.lengthComputable); } } async consume() { await this.pump(); this.blob = new Blob(this.buffer); this.buffer = null; return this.blob; } firefoxConstructor(input, init = {}, onprogress = init.onprogress, onabort = init.onabort, onerror = init.onerror) { if (!top.navigator.userAgent.includes('Firefox')) return false; this.onprogress = onprogress; this.onabort = onabort; this.onerror = onerror; this.abort = null; this.loaded = init.cacheLoaded || 0; this.total = init.cacheLoaded || 0; this.lengthComputable = false; this.buffer = []; this.blob = null; this.reader = undefined; this.blobPromise = new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); xhr.responseType = 'moz-chunked-arraybuffer'; xhr.onload = () => { resolve(this.blob = new Blob(this.buffer)); this.buffer = null; } let cacheLoaded = this.loaded; xhr.onprogress = e => { this.loaded = e.loaded + cacheLoaded; this.total = e.total + cacheLoaded; this.lengthComputable = e.lengthComputable; this.buffer.push(new Blob([xhr.response])); if (this.onprogress) this.onprogress(this.loaded, this.total, this.lengthComputable); }; xhr.onabort = e => this.onabort({ target: this, type: 'abort' }); xhr.onerror = e => { this.onerror({ target: this, type: e.type }); reject(e); }; this.abort = xhr.abort.bind(xhr); xhr.open('get', input); xhr.send(); }); this.promise = this.blobPromise; this.then = this.promise.then.bind(this.promise); this.catch = this.promise.catch.bind(this.promise); return true; } } class Mutex { constructor() { this.queueTail = Promise.resolve(); this.resolveHead = null; } async lock() { let myResolve; let _queueTail = this.queueTail; this.queueTail = new Promise(resolve => myResolve = resolve); await _queueTail; this.resolveHead = myResolve; return; } unlock() { this.resolveHead(); return; } async lockAndAwait(asyncFunc) { await this.lock(); let ret; try { ret = await asyncFunc(); } finally { this.unlock(); } return ret; } static _UNIT_TEST() { let m = new Mutex(); function sleep(time) { return new Promise(r => setTimeout(r, time)); } m.lockAndAwait(() => { console.warn('Check message timestamps.'); console.warn('Bad:'); console.warn('1 1 1 1 1:5s'); console.warn(' 1 1 1 1 1:10s'); console.warn('Good:'); console.warn('1 1 1 1 1:5s'); console.warn(' 1 1 1 1 1:10s'); }); m.lockAndAwait(async () => { await sleep(1000); await sleep(1000); await sleep(1000); await sleep(1000); await sleep(1000); }); m.lockAndAwait(async () => console.log('5s!')); m.lockAndAwait(async () => { await sleep(1000); await sleep(1000); await sleep(1000); await sleep(1000); await sleep(1000); }); m.lockAndAwait(async () => console.log('10s!')); } } class AsyncContainer { // Yes, this is something like cancelable Promise. But I insist they are different. constructor() { //this.state = 0; // I do not know why will I need this. this.resolve = null; this.reject = null; this.hang = null; this.hangReturn = Symbol(); this.primaryPromise = new Promise((s, j) => { this.resolve = arg => { s(arg); return arg; } this.reject = arg => { j(arg); return arg; } }); //this.primaryPromise.then(() => this.state = 1); //this.primaryPromise.catch(() => this.state = 2); this.hangPromise = new Promise(s => this.hang = () => s(this.hangReturn)); //this.hangPromise.then(() => this.state = 3); this.promise = Promise .race([this.primaryPromise, this.hangPromise]) .then(s => s == this.hangReturn ? new Promise(() => { }) : s); this.then = this.promise.then.bind(this.promise); this.catch = this.promise.catch.bind(this.promise); this.destroiedThen = this.hangPromise.then.bind(this.hangPromise); } destroy() { this.hang(); this.resolve = () => { }; this.reject = this.resolve; this.hang = this.resolve; this.primaryPromise = null; this.hangPromise = null; this.promise = null; this.then = this.resolve; this.catch = this.resolve; this.destroiedThen = f => f(); // Do NEVER NEVER NEVER dereference hangReturn. // Mysteriously this tiny symbol will keep you from Memory LEAK. //this.hangReturn = null; } static _UNIT_TEST() { let containers = []; async function foo() { let buf = new ArrayBuffer(600000000); let ac = new AsyncContainer(); ac.destroiedThen(() => console.log('asyncContainer destroied')) containers.push(ac); await ac; return buf; } let foos = [foo(), foo(), foo()]; containers.forEach(e => e.destroy()); console.warn('Check your RAM usage. I allocated 1.8GB in three dead-end promises.') return [foos, containers]; } } class ASSDownloader { constructor(option) { ({ fetchDanmaku: this.fetchDanmaku, generateASS: this.generateASS, setPosition: this.setPosition } = new Function('option', ` // ==UserScript== // @name bilibili ASS Danmaku Downloader // @namespace https://github.com/tiansh // @description 以 ASS 格式下载 bilibili 的弹幕 // @include http://www.bilibili.com/video/av* // @include http://bangumi.bilibili.com/movie/* // @updateURL https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.meta.js // @downloadURL https://tiansh.github.io/us-danmaku/bilibili/bilibili_ASS_Danmaku_Downloader.user.js // @version 1.11 // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-start // @author 田生 // @copyright 2014+, 田生 // @license Mozilla Public License 2.0; http://www.mozilla.org/MPL/2.0/ // @license CC Attribution-ShareAlike 4.0 International; http://creativecommons.org/licenses/by-sa/4.0/ // @connect-src comment.bilibili.com // @connect-src interface.bilibili.com // ==/UserScript== /* * Common */ // 设置项 var config = { 'playResX': 560, // 屏幕分辨率宽(像素) 'playResY': 420, // 屏幕分辨率高(像素) 'fontlist': [ // 字形(会自动选择最前面一个可用的) 'SimHei', '\\'Microsoft JhengHei\\'', 'SimSun', 'NSimSun', 'FangSong', '\\'Microsoft YaHei\\'', '\\'Microsoft Yahei UI Light\\'', '\\'Noto Sans CJK SC Bold\\'', '\\'Noto Sans CJK SC DemiLight\\'', '\\'Noto Sans CJK SC Regular\\'', 'Microsoft YaHei UI', 'Microsoft YaHei', '文泉驿正黑', 'STHeitiSC', '黑体', ], 'bold': 1, // 加粗(0/1) 'font_size': 1.0, // 字号(比例) 'r2ltime': 8, // 右到左弹幕持续时间(秒) 'fixtime': 4, // 固定弹幕持续时间(秒) 'opacity': 0.6, // 不透明度(比例) 'space': 0, // 弹幕间隔的最小水平距离(像素) 'max_delay': 6, // 最多允许延迟几秒出现弹幕 'bottom': 50, // 底端给字幕保留的空间(像素) 'use_canvas': null, // 是否使用canvas计算文本宽度(布尔值,Linux下的火狐默认否,其他默认是,Firefox bug #561361) 'debug': false, // 打印调试信息 }; if (option instanceof Object) { for (var prop in config) { if (prop in option) { config[prop] = option[prop] } } } var debug = config.debug ? console.log.bind(console) : function () { }; // 将字典中的值填入字符串 var fillStr = function (str) { var dict = Array.apply(Array, arguments).slice(1); return str.replace(/{{([^}]+)}}/g, function (r, o) { var ret; dict.some(function (i) { return ret = i[o]; }); return ret || ''; }); }; // 将颜色的数值化为十六进制字符串表示 var RRGGBB = function (color) { var t = Number(color).toString(16).toUpperCase(); return (Array(7).join('0') + t).slice(-6); }; // 将可见度转换为透明度 var hexAlpha = function (opacity) { var alpha = Math.round(0xFF * (1 - opacity)).toString(16).toUpperCase(); return Array(3 - alpha.length).join('0') + alpha; }; // 字符串 var funStr = function (fun) { return fun.toString().split(/\\r\\n|\\n|\\r/).slice(1, -1).join('\\n'); }; // 平方和开根 var hypot = Math.hypot ? Math.hypot.bind(Math) : function () { return Math.sqrt([0].concat(Array.apply(Array, arguments)) .reduce(function (x, y) { return x + y * y; })); }; // 创建下载 var startDownload = function (data, filename) { var blob = new Blob([data], { type: 'application/octet-stream' }); var url = window.URL.createObjectURL(blob); var saveas = document.createElement('a'); saveas.href = url; saveas.style.display = 'none'; document.body.appendChild(saveas); saveas.download = filename; saveas.click(); setTimeout(function () { saveas.parentNode.removeChild(saveas); }, 1000) document.addEventListener('unload', function () { window.URL.revokeObjectURL(url); }); }; // 计算文字宽度 var calcWidth = (function () { // 使用Canvas计算 var calcWidthCanvas = function () { var canvas = document.createElement("canvas"); var context = canvas.getContext("2d"); return function (fontname, text, fontsize) { context.font = 'bold ' + fontsize + 'px ' + fontname; return Math.ceil(context.measureText(text).width + config.space); }; } // 使用Div计算 var calcWidthDiv = function () { var d = document.createElement('div'); d.setAttribute('style', [ 'all: unset', 'top: -10000px', 'left: -10000px', 'width: auto', 'height: auto', 'position: absolute', '',].join(' !important; ')); var ld = function () { document.body.parentNode.appendChild(d); } if (!document.body) document.addEventListener('DOMContentLoaded', ld); else ld(); return function (fontname, text, fontsize) { d.textContent = text; d.style.font = 'bold ' + fontsize + 'px ' + fontname; return d.clientWidth + config.space; }; }; // 检查使用哪个测量文字宽度的方法 if (config.use_canvas === null) { if (navigator.platform.match(/linux/i) && !navigator.userAgent.match(/chrome/i)) config.use_canvas = false; } debug('use canvas: %o', config.use_canvas !== false); if (config.use_canvas === false) return calcWidthDiv(); return calcWidthCanvas(); }()); // 选择合适的字体 var choseFont = function (fontlist) { // 检查这个字串的宽度来检查字体是否存在 var sampleText = 'The quick brown fox jumps over the lazy dog' + '7531902468' + ',.!-' + ',。:!' + '天地玄黄' + '則近道矣'; // 和这些字体进行比较 var sampleFont = [ 'monospace', 'sans-serif', 'sans', 'Symbol', 'Arial', 'Comic Sans MS', 'Fixed', 'Terminal', 'Times', 'Times New Roman', '宋体', '黑体', '文泉驿正黑', 'Microsoft YaHei' ]; // 如果被检查的字体和基准字体可以渲染出不同的宽度 // 那么说明被检查的字体总是存在的 var diffFont = function (base, test) { var baseSize = calcWidth(base, sampleText, 72); var testSize = calcWidth(test + ',' + base, sampleText, 72); return baseSize !== testSize; }; var validFont = function (test) { var valid = sampleFont.some(function (base) { return diffFont(base, test); }); debug('font %s: %o', test, valid); return valid; }; // 找一个能用的字体 var f = fontlist[fontlist.length - 1]; fontlist = fontlist.filter(validFont); debug('fontlist: %o', fontlist); return fontlist[0] || f; }; // 从备选的字体中选择一个机器上提供了的字体 var initFont = (function () { var done = false; return function () { if (done) return; done = true; calcWidth = calcWidth.bind(window, config.font = choseFont(config.fontlist) ); }; }()); var generateASS = function (danmaku, info) { var assHeader = fillStr( '[Script Info]\\nTitle: {{title}}\\nOriginal Script: \\u6839\\u636E {{ori}} \\u7684\\u5F39\\u5E55\\u4FE1\\u606F\\uFF0C\\u7531 https://github.com/tiansh/us-danmaku \\u751F\\u6210\\nScriptType: v4.00+\\nCollisions: Normal\\nPlayResX: {{playResX}}\\nPlayResY: {{playResY}}\\nTimer: 10.0000\\n\\n[V4+ Styles]\\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\\nStyle: Fix,{{font}},{{font_size}},&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,{{bold}},0,0,0,100,100,0,0,1,2,0,2,20,20,2,0\\nStyle: R2L,{{font}},{{font_size}},&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,{{bold}},0,0,0,100,100,0,0,1,2,0,2,20,20,2,0\\n\\n[Events]\\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\\n', {'alpha': hexAlpha(config.opacity), 'font_size': 25 * config.font_size }, config, info ); // 补齐数字开头的0 var paddingNum = function (num, len) { num = '' + num; while (num.length < len) num = '0' + num; return num; }; // 格式化时间 var formatTime = function (time) { time = 100 * time ^ 0; var l = [[100, 2], [60, 2], [60, 2], [Infinity, 0]].map(function (c) { var r = time % c[0]; time = (time - r) / c[0]; return paddingNum(r, c[1]); }).reverse(); return l.slice(0, -1).join(':') + '.' + l[3]; }; // 格式化特效 var format = (function () { // 适用于所有弹幕 var common = function (line) { var s = ''; var rgb = line.color.split(/(..)/).filter(function (x) { return x; }) .map(function (x) { return parseInt(x, 16); }); // 如果不是白色,要指定弹幕特殊的颜色 if (line.color !== 'FFFFFF') // line.color 是 RRGGBB 格式 s += '\\\\c&H' + line.color.split(/(..)/).reverse().join(''); // 如果弹幕颜色比较深,用白色的外边框 var dark = rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 < 0x30; if (dark) s += '\\\\3c&HFFFFFF'; if (line.size !== 25) s += '\\\\fs' + line.size; return s; }; // 适用于从右到左弹幕 var r2l = function (line) { return '\\\\move(' + [ line.poss.x, line.poss.y, line.posd.x, line.posd.y ].join(',') + ')'; }; // 适用于固定位置弹幕 var fix = function (line) { return '\\\\pos(' + [ line.poss.x, line.poss.y ].join(',') + ')'; }; var withCommon = function (f) { return function (line) { return f(line) + common(line); }; }; return { 'R2L': withCommon(r2l), 'Fix': withCommon(fix), }; }()); // 转义一些字符 var escapeAssText = function (s) { // "{"、"}"字符libass可以转义,但是VSFilter不可以,所以直接用全角补上 return s.replace(/{/g, '{').replace(/}/g, '}').replace(/\\r|\\n/g, ''); }; // 将一行转换为ASS的事件 var convert2Ass = function (line) { return 'Dialogue: ' + [ 0, formatTime(line.stime), formatTime(line.dtime), line.type, ',20,20,2,,', ].join(',') + '{' + format[line.type](line) + '}' + escapeAssText(line.text); }; return assHeader + danmaku.map(convert2Ass) .filter(function (x) { return x; }) .join('\\n'); }; /* 下文字母含义: 0 ||----------------------x----------------------> _____________________c_____________________ = / wc \\ 0 | | |--v--| wv | |--v--| | d |--v--| d f |--v--| y |--v--| l f | s _ p | | VIDEO |--v--| |--v--| _ m v | AREA (x ^ y) | v: 弹幕 c: 屏幕 0: 弹幕发送 a: 可行方案 s: 开始出现 f: 出现完全 l: 开始消失 d: 消失完全 p: 上边缘(含) m: 下边缘(不含) w: 宽度 h: 高度 b: 底端保留 t: 时间点 u: 时间段 r: 延迟 并规定 ts := t0s + r tf := wv / (wc + ws) * p + ts tl := ws / (wc + ws) * p + ts td := p + ts */ // 滚动弹幕 var normalDanmaku = (function (wc, hc, b, u, maxr) { return function () { // 初始化屏幕外面是不可用的 var used = [ { 'p': -Infinity, 'm': 0, 'tf': Infinity, 'td': Infinity, 'b': false }, { 'p': hc, 'm': Infinity, 'tf': Infinity, 'td': Infinity, 'b': false }, { 'p': hc - b, 'm': hc, 'tf': Infinity, 'td': Infinity, 'b': true }, ]; // 检查一些可用的位置 var available = function (hv, t0s, t0l, b) { var suggestion = []; // 这些上边缘总之别的块的下边缘 used.forEach(function (i) { if (i.m > hc) return; var p = i.m; var m = p + hv; var tas = t0s; var tal = t0l; // 这些块的左边缘总是这个区域里面最大的边缘 used.forEach(function (j) { if (j.p >= m) return; if (j.m <= p) return; if (j.b && b) return; tas = Math.max(tas, j.tf); tal = Math.max(tal, j.td); }); // 最后作为一种备选留下来 suggestion.push({ 'p': p, 'r': Math.max(tas - t0s, tal - t0l), }); }); // 根据高度排序 suggestion.sort(function (x, y) { return x.p - y.p; }); var mr = maxr; // 又靠右又靠下的选择可以忽略,剩下的返回 suggestion = suggestion.filter(function (i) { if (i.r >= mr) return false; mr = i.r; return true; }); return suggestion; }; // 添加一个被使用的 var use = function (p, m, tf, td) { used.push({ 'p': p, 'm': m, 'tf': tf, 'td': td, 'b': false }); }; // 根据时间同步掉无用的 var syn = function (t0s, t0l) { used = used.filter(function (i) { return i.tf > t0s || i.td > t0l; }); }; // 给所有可能的位置打分,分数是[0, 1)的 var score = function (i) { if (i.r > maxr) return -Infinity; return 1 - hypot(i.r / maxr, i.p / hc) * Math.SQRT1_2; }; // 添加一条 return function (t0s, wv, hv, b) { var t0l = wc / (wv + wc) * u + t0s; syn(t0s, t0l); var al = available(hv, t0s, t0l, b); if (!al.length) return null; var scored = al.map(function (i) { return [score(i), i]; }); var best = scored.reduce(function (x, y) { return x[0] > y[0] ? x : y; })[1]; var ts = t0s + best.r; var tf = wv / (wv + wc) * u + ts; var td = u + ts; use(best.p, best.p + hv, tf, td); return { 'top': best.p, 'time': ts, }; }; }; }(config.playResX, config.playResY, config.bottom, config.r2ltime, config.max_delay)); // 顶部、底部弹幕 var sideDanmaku = (function (hc, b, u, maxr) { return function () { var used = [ { 'p': -Infinity, 'm': 0, 'td': Infinity, 'b': false }, { 'p': hc, 'm': Infinity, 'td': Infinity, 'b': false }, { 'p': hc - b, 'm': hc, 'td': Infinity, 'b': true }, ]; // 查找可用的位置 var fr = function (p, m, t0s, b) { var tas = t0s; used.forEach(function (j) { if (j.p >= m) return; if (j.m <= p) return; if (j.b && b) return; tas = Math.max(tas, j.td); }); return { 'r': tas - t0s, 'p': p, 'm': m }; }; // 顶部 var top = function (hv, t0s, b) { var suggestion = []; used.forEach(function (i) { if (i.m > hc) return; suggestion.push(fr(i.m, i.m + hv, t0s, b)); }); return suggestion; }; // 底部 var bottom = function (hv, t0s, b) { var suggestion = []; used.forEach(function (i) { if (i.p < 0) return; suggestion.push(fr(i.p - hv, i.p, t0s, b)); }); return suggestion; }; var use = function (p, m, td) { used.push({ 'p': p, 'm': m, 'td': td, 'b': false }); }; var syn = function (t0s) { used = used.filter(function (i) { return i.td > t0s; }); }; // 挑选最好的方案:延迟小的优先,位置不重要 var score = function (i, is_top) { if (i.r > maxr) return -Infinity; var f = function (p) { return is_top ? p : (hc - p); }; return 1 - (i.r / maxr * (31/32) + f(i.p) / hc * (1/32)); }; return function (t0s, hv, is_top, b) { syn(t0s); var al = (is_top ? top : bottom)(hv, t0s, b); if (!al.length) return null; var scored = al.map(function (i) { return [score(i, is_top), i]; }); var best = scored.reduce(function (x, y) { return x[0] > y[0] ? x : y; })[1]; use(best.p, best.m, best.r + t0s + u) return { 'top': best.p, 'time': best.r + t0s }; }; }; }(config.playResY, config.bottom, config.fixtime, config.max_delay)); // 为每条弹幕安置位置 var setPosition = function (danmaku) { var normal = normalDanmaku(), side = sideDanmaku(); return danmaku .sort(function (x, y) { return x.time - y.time; }) .map(function (line) { var font_size = Math.round(line.size * config.font_size); var width = calcWidth(line.text, font_size); switch (line.mode) { case 'R2L': return (function () { var pos = normal(line.time, width, font_size, line.bottom); if (!pos) return null; line.type = 'R2L'; line.stime = pos.time; line.poss = { 'x': config.playResX + width / 2, 'y': pos.top + font_size, }; line.posd = { 'x': -width / 2, 'y': pos.top + font_size, }; line.dtime = config.r2ltime + line.stime; return line; }()); case 'TOP': case 'BOTTOM': return (function (isTop) { var pos = side(line.time, font_size, isTop, line.bottom); if (!pos) return null; line.type = 'Fix'; line.stime = pos.time; line.posd = line.poss = { 'x': Math.round(config.playResX / 2), 'y': pos.top + font_size, }; line.dtime = config.fixtime + line.stime; return line; }(line.mode === 'TOP')); default: return null; }; }) .filter(function (l) { return l; }) .sort(function (x, y) { return x.stime - y.stime; }); }; /* * bilibili */ // 获取xml var fetchXML = function (cid, callback) { GM_xmlhttpRequest({ 'method': 'GET', 'url': 'http://comment.bilibili.com/{{cid}}.xml'.replace('{{cid}}', cid), 'onload': function (resp) { var content = resp.responseText.replace(/(?:[\\0-\\x08\\x0B\\f\\x0E-\\x1F\\uFFFE\\uFFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF])/g, ""); callback(content); } }); }; var fetchDanmaku = function (cid, callback) { fetchXML(cid, function (content) { callback(parseXML(content)); }); }; var parseXML = function (content) { var data = (new DOMParser()).parseFromString(content, 'text/xml'); return Array.apply(Array, data.querySelectorAll('d')).map(function (line) { var info = line.getAttribute('p').split(','), text = line.textContent; return { 'text': text, 'time': Number(info[0]), 'mode': [undefined, 'R2L', 'R2L', 'R2L', 'BOTTOM', 'TOP'][Number(info[1])], 'size': Number(info[2]), 'color': RRGGBB(parseInt(info[3], 10) & 0xffffff), 'bottom': Number(info[5]) > 0, // 'create': new Date(Number(info[4])), // 'pool': Number(info[5]), // 'sender': String(info[6]), // 'dmid': Number(info[7]), }; }); }; fetchXML = function (cid, callback) { var oReq = new XMLHttpRequest(); oReq.open('GET', 'https://comment.bilibili.com/{{cid}}.xml'.replace('{{cid}}', cid)); oReq.onload = function () { var content = oReq.responseText.replace(/(?:[\\0-\\x08\\x0B\\f\\x0E-\\x1F\\uFFFE\\uFFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF])/g, ""); callback(content); }; oReq.send(); }; initFont(); return { fetchDanmaku: fetchDanmaku, generateASS: generateASS, setPosition: setPosition }; `)(option)); } fetchDanmaku() { } generateASS() { } setPosition() { } } class MKVTransmuxer { constructor(option) { this.playerWin = null; this.option = option; } exec(flv, ass, name) { // 1. Allocate for a new window if (!this.playerWin) this.playerWin = top.open('', undefined, ' '); // 2. Inject scripts this.playerWin.document.write(`

加载文件…… loading files...

构建mkv…… building mkv...

merged.mkv

`); // 3. Invoke exec if (!(this.option instanceof Object)) this.option = null; this.playerWin.exec(Object.assign({}, this.option, { flv, ass, name })); URL.revokeObjectURL(flv); URL.revokeObjectURL(ass); // 4. Free parent window // if (top.confirm('MKV打包中……要关掉这个窗口,释放内存吗?')) top.location = 'about:blank'; } } class BiliMonkey { constructor(playerWin, option = { cache: null, partial: false, proxy: false, blocker: false, font: false }) { this.playerWin = playerWin; this.protocol = playerWin.location.protocol; this.cid = null; this.flvs = null; this.mp4 = null; this.ass = null; this.flvFormatName = null; this.mp4FormatName = null; this.cidAsyncContainer = new AsyncContainer(); this.cidAsyncContainer.then(cid => { this.cid = cid; this.ass = this.getASS(); }); if (typeof top.cid === 'string') this.cidAsyncContainer.resolve(top.cid); /* cache + proxy = Service Worker * Hope bilibili will have a SW as soon as possible. * partial = Stream * Hope the fetch API will be stabilized as soon as possible. * If you are using your grandpa's browser, do not enable these functions. **/ this.cache = option.cache; this.partial = option.partial; this.proxy = option.proxy; this.blocker = option.blocker; this.font = option.font; this.option = option; if (this.cache && (!(this.cache instanceof CacheDB))) this.cache = new CacheDB('biliMonkey', 'flv', 'name'); this.flvsDetailedFetch = []; this.flvsBlob = []; this.defaultFormatPromise = null; this.queryInfoMutex = new Mutex(); this.queryInfoMutex.lockAndAwait(() => this.getPlayerButtons()); this.queryInfoMutex.lockAndAwait(() => this.getAvailableFormatName()); } lockFormat(format) { // null => uninitialized // async pending => another one is working on it // async resolve => that guy just finished work // sync value => someone already finished work let h = this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-top')[0]; if (h) h.style.visibility = 'hidden'; switch (format) { // Single writer is not a must. // Plus, if one writer fail, others should be able to overwrite its garbage. case 'flv_p60': case 'flv720_p60': case 'hdflv2': case 'flv': case 'flv720': case 'flv480': case 'flv320': //if (this.flvs) return this.flvs; return this.flvs = new AsyncContainer(); case 'hdmp4': case 'mp4': //if (this.mp4) return this.mp4; return this.mp4 = new AsyncContainer(); default: throw `lockFormat error: ${format} is a unrecognizable format`; } } resolveFormat(res, shouldBe) { let h = this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-top')[0]; if (h) { h.style.visibility = ''; if (h.children.length) h.children[0].style.visibility = 'hidden'; let i = e => { if (h.children.length) h.children[0].style.visibility = 'hidden'; e.target.removeEventListener(e.type, i); }; let j = this.playerWin.document.getElementsByTagName('video')[0]; if (j) j.addEventListener('emptied', i); } switch (res.format) { case 'flv_p60': case 'flv720_p60': case 'hdflv2': case 'flv': case 'flv720': case 'flv480': case 'flv320': if (shouldBe && shouldBe != res.format) { this.flvs = null; throw `URL interface error: response is not ${shouldBe}`; } return this.flvs = this.flvs.resolve(res.durl.map(e => e.url.replace('http:', this.protocol))); case 'hdmp4': case 'mp4': if (shouldBe && shouldBe != res.format) { this.mp4 = null; throw `URL interface error: response is not ${shouldBe}`; } return this.mp4 = this.mp4.resolve(res.durl[0].url.replace('http:', this.protocol)); default: throw `resolveFormat error: ${res.format} is a unrecognizable format`; } } getAvailableFormatName(accept_quality) { if (!Array.isArray(accept_quality)) accept_quality = Array.from(this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul').getElementsByTagName('li')).map(e => e.getAttribute('data-value')); accept_quality = accept_quality.map(e => e.toString()); if (accept_quality.includes('80')) this.flvFormatName = 'flv'; else if (accept_quality.includes('64')) this.flvFormatName = 'flv720'; else if (accept_quality.includes('32')) this.flvFormatName = 'flv480'; else if (accept_quality.includes('15')) this.flvFormatName = 'flv320'; else this.flvFormatName = 'does_not_exist'; if (accept_quality.includes('16')) this.mp4FormatName = 'mp4'; else this.mp4FormatName = 'does_not_exist'; } async execOptions() { if (this.cache) await this.cache.getDB(); if (this.option.autoDefault) await this.sniffDefaultFormat(); if (this.option.autoFLV) this.queryInfo('flv'); if (this.option.autoMP4) this.queryInfo('mp4'); } async sniffDefaultFormat() { if (this.defaultFormatPromise) return this.defaultFormatPromise; if (this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li')) return this.defaultFormatPromise = Promise.resolve(); const jq = this.playerWin.jQuery; const _ajax = jq.ajax; this.defaultFormatPromise = new Promise(resolve => { let timeout = setTimeout(() => { jq.ajax = _ajax; resolve(); }, 5000); let self = this; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { clearTimeout(timeout); self.cidAsyncContainer.resolve(a.url.match(/cid=\d+/)[0].slice(4)); let _success = a.success; a.success = res => { let format = res.format; let accept_format = res.accept_format.split(','); switch (format) { case 'flv360': if (accept_format.includes('flv480')) break; case 'flv480': if (accept_format.includes('flv720')) break; case 'flv720': if (accept_format.includes('flv')) break; case 'flv720_p60': if (accept_format.includes('flv_p60')) break; case 'flv': case 'hdflv2': case 'flv_p60': self.lockFormat(format); self.resolveFormat(res, format); break; case 'mp4': if (accept_format.includes('hdmp4')) break; case 'hdmp4': self.lockFormat(format); self.resolveFormat(res, format); break; } if (self.proxy && self.flvs) { self.setupProxy(res, _success); } else { _success(res); } resolve(res); }; jq.ajax = _ajax; } return _ajax.call(jq, a, c); }; }); return this.defaultFormatPromise; } async getBackgroundFormat(format) { if (format == 'hdmp4' || format == 'mp4') { let src = this.playerWin.document.getElementsByTagName('video')[0].src; if ((src.includes('hd') || format == 'mp4') && src.includes('.mp4')) { let pendingFormat = this.lockFormat(format); this.resolveFormat({ durl: [{ url: src }] }, format); return pendingFormat; } } const jq = this.playerWin.jQuery; const _ajax = jq.ajax; let pendingFormat = this.lockFormat(format); let self = this; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { self.cidAsyncContainer.resolve(a.url.match(/cid=\d+/)[0].slice(4)); let _success = a.success; a.success = res => { if (format == 'hdmp4') res.durl = [res.durl[0].backup_url.find(e => e.includes('hd') && e.includes('.mp4'))]; if (format == 'mp4') res.durl = [res.durl[0].backup_url.find(e => !e.includes('hd') && e.includes('.mp4'))]; self.resolveFormat(res, format); }; jq.ajax = _ajax; } return _ajax.call(jq, a, c); }; this.playerWin.player.reloadAccess(); return pendingFormat; } async getCurrentFormat(format) { const jq = this.playerWin.jQuery; const _ajax = jq.ajax; const _setItem = this.playerWin.localStorage.setItem; const siblingFormat = format == this.flvFormatName ? this.mp4FormatName : this.flvFormatName; const fakedRes = { 'from': 'local', 'result': 'suee', 'format': 'faked_mp4', 'timelength': 10, 'accept_format': 'hdflv2,flv,hdmp4,faked_mp4,mp4', 'accept_quality': [112, 80, 64, 32, 16], 'seek_param': 'start', 'seek_type': 'second', 'durl': [{ 'order': 1, 'length': 1000, 'size': 30000, 'url': 'https://static.hdslb.com/encoding.mp4', 'backup_url': ['https://static.hdslb.com/encoding.mp4'] }] }; let pendingFormat = this.lockFormat(format); let self = this; let blockedRequest = await new Promise(resolve => { jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { // Send back a fake response to enable the change-format button. self.cidAsyncContainer.resolve(a.url.match(/cid=\d+/)[0].slice(4)); a.success(fakedRes); self.playerWin.document.getElementsByTagName('video')[1].loop = true; let h = e => { resolve([a, c]); e.target.removeEventListener(e.type, h); }; self.playerWin.document.getElementsByTagName('video')[0].addEventListener('emptied', h); } else { return _ajax.call(jq, a, c); } }; this.playerWin.localStorage.setItem = () => this.playerWin.localStorage.setItem = _setItem; this.playerWin.document.querySelector(`div.bilibili-player-video-btn-quality > div ul li[data-value="${BiliMonkey.formatToValue(siblingFormat)}"]`).click(); }); let siblingOK = siblingFormat == this.flvFormatName ? this.flvs : this.mp4; if (!siblingOK) { this.lockFormat(siblingFormat); blockedRequest[0].success = res => this.resolveFormat(res, siblingFormat); _ajax.call(jq, ...blockedRequest); } jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { let _success = a.success; a.success = res => { if (self.proxy) { self.resolveFormat(res, format); if (self.flvs) self.setupProxy(res, _success); } else { _success(res); self.resolveFormat(res, format); } }; jq.ajax = _ajax; } return _ajax.call(jq, a, c); }; this.playerWin.localStorage.setItem = () => this.playerWin.localStorage.setItem = _setItem; this.playerWin.document.querySelector(`div.bilibili-player-video-btn-quality > div ul li[data-value="${BiliMonkey.formatToValue(format)}"]`).click(); return pendingFormat; } async getNonCurrentFormat(format) { const jq = this.playerWin.jQuery; const _ajax = jq.ajax; const _setItem = this.playerWin.localStorage.setItem; let pendingFormat = this.lockFormat(format); let self = this; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { self.cidAsyncContainer.resolve(a.url.match(/cid=\d+/)[0].slice(4)); let _success = a.success; _success({}); a.success = res => self.resolveFormat(res, format); jq.ajax = _ajax; } return _ajax.call(jq, a, c); }; this.playerWin.localStorage.setItem = () => this.playerWin.localStorage.setItem = _setItem; this.playerWin.document.querySelector(`div.bilibili-player-video-btn-quality > div ul li[data-value="${BiliMonkey.formatToValue(format)}"]`).click(); return pendingFormat; } async getASS(clickableFormat) { if (this.ass) return this.ass; this.ass = new Promise(async resolve => { if (!this.cid) this.cid = await new Promise(resolve => { if (!clickableFormat) reject('get ASS Error: cid unavailable, nor clickable format given.'); const jq = this.playerWin.jQuery; const _ajax = jq.ajax; const _setItem = this.playerWin.localStorage.setItem; this.lockFormat(clickableFormat); let self = this; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { resolve(self.cid = a.url.match(/cid=\d+/)[0].slice(4)); let _success = a.success; _success({}); a.success = res => self.resolveFormat(res, clickableFormat); jq.ajax = _ajax; } return _ajax.call(jq, a, c); }; this.playerWin.localStorage.setItem = () => this.playerWin.localStorage.setItem = _setItem; this.playerWin.document.querySelector(`div.bilibili-player-video-btn-quality > div ul li[data-value="${BiliMonkey.formatToValue(clickableFormat)}"]`).click(); }); let bilibili_player_settings = this.playerWin.localStorage.bilibili_player_settings && JSON.parse(this.playerWin.localStorage.bilibili_player_settings); let option = bilibili_player_settings && this.font && { 'fontlist': bilibili_player_settings.setting_config['fontfamily'] != 'custom' ? bilibili_player_settings.setting_config['fontfamily'].split(/, ?/) : bilibili_player_settings.setting_config['fontfamilycustom'].split(/, ?/), 'font_size': parseFloat(bilibili_player_settings.setting_config['fontsize']), 'opacity': parseFloat(bilibili_player_settings.setting_config['opacity']), 'bold': bilibili_player_settings.setting_config['bold'] ? 1 : 0, } || undefined; const { fetchDanmaku, generateASS, setPosition } = new ASSDownloader(option); fetchDanmaku(this.cid, danmaku => { if (bilibili_player_settings && this.blocker) { let regexps = bilibili_player_settings.block.list.map(e => e.v).join('|'); if (regexps) { regexps = new RegExp(regexps); danmaku = danmaku.filter(d => !regexps.test(d.text)); } } let ass = generateASS(setPosition(danmaku), { 'title': top.document.title, 'ori': top.location.href, }); // I would assume most users are using Windows let blob = new Blob(['\ufeff' + ass], { type: 'application/octet-stream' }); resolve(this.ass = top.URL.createObjectURL(blob)); }); }); return this.ass; } async queryInfo(format) { return this.queryInfoMutex.lockAndAwait(async () => { switch (format) { case 'flv': if (this.flvs) return this.flvs; else if (this.flvFormatName == 'does_not_exist') return this.flvFormatName; else if (this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li[data-selected]').getAttribute('data-value') == BiliMonkey.formatToValue(this.flvFormatName)) return this.getCurrentFormat(this.flvFormatName); else return this.getNonCurrentFormat(this.flvFormatName); case 'mp4': if (this.mp4) return this.mp4; else if (this.mp4FormatName == 'does_not_exist') return this.mp4FormatName; else if (this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li[data-selected]').getAttribute('data-value') == BiliMonkey.formatToValue(this.mp4FormatName)) return this.getCurrentFormat(this.mp4FormatName); else return this.getNonCurrentFormat(this.mp4FormatName); case 'ass': if (this.ass) return this.ass; else if (this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li[data-selected]').getAttribute('data-value') == BiliMonkey.formatToValue(this.flvFormatName)) return this.getASS(this.mp4FormatName); else return this.getASS(this.flvFormatName); default: throw `Bilimonkey: What is format ${format}?`; } }); } async getPlayerButtons() { if (this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li')) { return this.playerWin; } else { return new Promise(resolve => { let observer = new MutationObserver(() => { if (this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li')) { observer.disconnect(); resolve(this.playerWin); } }); observer.observe(this.playerWin.document.getElementById('bilibiliPlayer'), { childList: true }); }); } } async hangPlayer() { const fakedRes = { 'from': 'local', 'result': 'suee', 'format': 'faked_mp4', 'timelength': 10, 'accept_format': 'hdflv2,flv,hdmp4,faked_mp4,mp4', 'accept_quality': [112, 80, 64, 32, 16], 'seek_param': 'start', 'seek_type': 'second', 'durl': [{ 'order': 1, 'length': 1000, 'size': 30000, 'url': '' }] }; const jq = this.playerWin.jQuery; const _ajax = jq.ajax; const _setItem = this.playerWin.localStorage.setItem; return this.queryInfoMutex.lockAndAwait(() => new Promise(async resolve => { let blockerTimeout; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { clearTimeout(blockerTimeout); a.success(fakedRes); blockerTimeout = setTimeout(() => { jq.ajax = _ajax; resolve(); }, 2500); } else { return _ajax.call(jq, a, c); } }; this.playerWin.localStorage.setItem = () => this.playerWin.localStorage.setItem = _setItem; let button = Array.from(this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul').getElementsByTagName('li')) .find(e => !e.getAttribute('data-selected') && !e.children.length); button.click(); })); } async loadFLVFromCache(index) { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let name = this.flvs[index].match(/\d+-\d+(?:-\d+)?\.flv/)[0]; let item = await this.cache.getData(name); if (!item) return; return this.flvsBlob[index] = item.data; } async loadPartialFLVFromCache(index) { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let name = this.flvs[index].match(/\d+-\d+(?:-\d+)?\.flv/)[0]; name = 'PC_' + name; let item = await this.cache.getData(name); if (!item) return; return item.data; } async loadAllFLVFromCache() { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let promises = []; for (let i = 0; i < this.flvs.length; i++) promises.push(this.loadFLVFromCache(i)); return Promise.all(promises); } async saveFLVToCache(index, blob) { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let name = this.flvs[index].match(/\d+-\d+(?:-\d+)?\.flv/)[0]; return this.cache.addData({ name, data: blob }); } async savePartialFLVToCache(index, blob) { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let name = this.flvs[index].match(/\d+-\d+(?:-\d+)?\.flv/)[0]; name = 'PC_' + name; return this.cache.putData({ name, data: blob }); } async cleanPartialFLVInCache(index) { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let name = this.flvs[index].match(/\d+-\d+(?:-\d+)?\.flv/)[0]; name = 'PC_' + name; return this.cache.deleteData(name); } async getFLV(index, progressHandler) { if (this.flvsBlob[index]) return this.flvsBlob[index]; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; this.flvsBlob[index] = (async () => { let cache = await this.loadFLVFromCache(index); if (cache) return this.flvsBlob[index] = cache; let partialFLVFromCache = await this.loadPartialFLVFromCache(index); let burl = this.flvs[index]; if (partialFLVFromCache) burl += `&bstart=${partialFLVFromCache.size}`; let opt = { fetch: this.playerWin.fetch, method: 'GET', mode: 'cors', cache: 'default', referrerPolicy: 'no-referrer-when-downgrade', cacheLoaded: partialFLVFromCache ? partialFLVFromCache.size : 0, headers: partialFLVFromCache && (!burl.includes('wsTime')) ? { Range: `bytes=${partialFLVFromCache.size}-` } : undefined }; opt.onprogress = progressHandler; opt.onerror = opt.onabort = ({ target, type }) => { let partialFLV = target.getPartialBlob(); if (partialFLVFromCache) partialFLV = new Blob([partialFLVFromCache, partialFLV]); this.savePartialFLVToCache(index, partialFLV); } let fch = new DetailedFetchBlob(burl, opt); this.flvsDetailedFetch[index] = fch; let fullFLV = await fch.getBlob(); this.flvsDetailedFetch[index] = undefined; if (partialFLVFromCache) { fullFLV = new Blob([partialFLVFromCache, fullFLV]); this.cleanPartialFLVInCache(index); } this.saveFLVToCache(index, fullFLV); return (this.flvsBlob[index] = fullFLV); })(); return this.flvsBlob[index]; } async abortFLV(index) { if (this.flvsDetailedFetch[index]) return this.flvsDetailedFetch[index].abort(); } async getAllFLVs(progressHandler) { if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let promises = []; for (let i = 0; i < this.flvs.length; i++) promises.push(this.getFLV(i, progressHandler)); return Promise.all(promises); } async cleanAllFLVsInCache() { if (!this.cache) return; if (!this.flvs) throw 'BiliMonkey: info uninitialized'; let promises = []; for (let flv of this.flvs) { let name = flv.match(/\d+-\d+(?:-\d+)?\.flv/)[0]; promises.push(this.cache.deleteData(name)); promises.push(this.cache.deleteData('PC_' + name)); } return Promise.all(promises); } async setupProxy(res, onsuccess) { (() => { let _fetch = this.playerWin.fetch; this.playerWin.fetch = function (input, init) { if (!input.slice || input.slice(0, 5) != 'blob:') { return _fetch(input, init); } let bstart = input.indexOf('?bstart='); if (bstart < 0) { return _fetch(input, init); } if (!init.headers instanceof Headers) init.headers = new Headers(init.headers || {}); init.headers.set('Range', `bytes=${input.slice(bstart + 8)}-`); return _fetch(input.slice(0, bstart), init) } })(); await this.loadAllFLVFromCache(); let resProxy = Object.assign({}, res); for (let i = 0; i < this.flvsBlob.length; i++) { if (this.flvsBlob[i]) resProxy.durl[i].url = this.playerWin.URL.createObjectURL(this.flvsBlob[i]); } return onsuccess(resProxy); } static async getAllPageDefaultFormats(playerWin = top) { const jq = playerWin.jQuery; const _ajax = jq.ajax; const queryInfoMutex = new Mutex(); const { fetchDanmaku, generateASS, setPosition } = new ASSDownloader(); const list = await new Promise(resolve => { const i = setInterval(() => { const ret = playerWin.player.getPlaylist(); if (ret) { clearInterval(i); resolve(ret); } }, 500); }); const index = list.reduce((acc, cur) => { acc[cur.cid] = cur; return acc }, {}); const end = list[list.length - 1].cid.toString(); const ret = []; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('comment.bilibili.com') || a.url.includes('interface.bilibili.com/player?') || a.url.includes('api.bilibili.com/x/player/playurl/token')) return _ajax.call(jq, a, c); if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { (async () => { a.success = undefined; let cid = a.url.match(/cid=\d+/)[0].slice(4); const [danmuku, res] = await Promise.all([ new Promise(resolve => { fetchDanmaku(cid, danmaku => { let ass = generateASS(setPosition(danmaku), { 'title': document.title, 'ori': location.href, }); // I would assume most users are using Windows let blob = new Blob(['\ufeff' + ass], { type: 'application/octet-stream' }); resolve(playerWin.URL.createObjectURL(blob)); }); }), _ajax.call(jq, a, c) ]); ret.push({ durl: res.durl.map(({ url }) => url.replace('http:', playerWin.location.protocol)), danmuku, name: index[cid].part || index[cid].index, outputName: res.durl[0].url.match(/\d+-\d+(?:-\d+)?(?=\.flv)/) ? res.durl[0].url.match(/\d+-\d+(?:-\d+)?(?=\.flv)/)[0].replace(/(?<=\d+)-\d+(?=(?:-\d+)?\.flv)/, '') : res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/) ? res.durl[0].url.match(/\d(?:\d|-|hd)*(?=\.mp4)/) : cid, cid, res, }); queryInfoMutex.unlock(); })(); } return _ajax.call(jq, { url: '//0.0.0.0' }); }; await queryInfoMutex.lock(); playerWin.player.next(1); while (1) { await queryInfoMutex.lock(); if (ret[ret.length - 1].cid == end) break; playerWin.player.next(); } let table = [` `, `

(测试) 批量抓取

`, ` `]; for (let i of ret) { table.push(` `); for (let j of i.durl.slice(1)) { table.push(` `); } } table.push(`
标题 视频(flv/mp4) e.durl)), top.location.origin)]))}>批量导出IDM 弹幕(ass)
${i.name} ${i.durl[0]} ${i.danmuku}
${j}
`); playerWin.document.write(table.join('')); return ret; } static formatToValue(format) { switch (format) { case 'flv_p60': return '116'; case 'flv720_p60': return '74'; case 'flv': return '80'; case 'flv720': return '64'; case 'flv480': return '32'; case 'flv320': return '15'; // legacy - late 2017 case 'hdflv2': return '112'; case 'hdmp4': return '64'; // data-value is still '64' instead of '48'. return '48'; case 'mp4': return '16'; default: return null; } } static valueToFormat(value) { switch (parseInt(value)) { case 116: return 'flv_p60'; case 74: return 'flv720_p60'; case 80: return 'flv'; case 64: return 'flv720'; case 32: return 'flv480'; case 15: return 'flv320'; // legacy - late 2017 case 112: return 'hdflv2'; case 48: return 'hdmp4'; case 16: return 'mp4'; // legacy - early 2017 case 3: return 'flv'; case 2: return 'hdmp4'; case 1: return 'mp4'; default: return null; } } static _UNIT_TEST() { (async () => { let playerWin = await BiliUserJS.getPlayerWin(); window.m = new BiliMonkey(playerWin); console.warn('sniffDefaultFormat test'); await m.sniffDefaultFormat(); console.log(m); console.warn('data race test'); m.queryInfo('mp4'); console.log(m.queryInfo('mp4')); console.warn('getNonCurrentFormat test'); console.log(await m.queryInfo('mp4')); console.warn('getCurrentFormat test'); console.log(await m.queryInfo('flv')); //location.reload(); })(); } } class BiliPolyfill { constructor(playerWin, option = { setStorage: (n, i) => playerWin.localStorage.setItem(n, i), getStorage: n => playerWin.localStorage.getItem(n), badgeWatchLater: true, dblclick: true, scroll: true, recommend: true, electric: true, electricSkippable: false, lift: true, autoResume: true, autoPlay: false, autoWideScreen: false, autoFullScreen: false, oped: true, focus: true, menuFocus: true, limitedKeydown: true, speech: false, series: true, }, hintInfo = () => { }) { this.playerWin = playerWin; this.video = null; this.vanillaPlayer = null; this.option = option; this.setStorage = option.setStorage; this.getStorage = option.getStorage; this.hintInfo = hintInfo; this.series = []; this.userdata = { oped: {} }; } saveUserdata() { this.setStorage('biliPolyfill', JSON.stringify(this.userdata)); } retrieveUserdata() { try { this.userdata = this.getStorage('biliPolyfill'); if (this.userdata.length > 1073741824) top.alert('BiliPolyfill脚本数据已经快满了,在播放器上右键->BiliPolyfill->片头片尾->检视数据,删掉一些吧。'); this.userdata = JSON.parse(this.userdata); } catch (e) { } finally { if (!this.userdata) this.userdata = {}; if (!(this.userdata.oped instanceof Object)) this.userdata.oped = {}; } } async setFunctions({ videoRefresh = false } = {}) { // 1. Initialize this.video = await this.getPlayerVideo(); // 2. If not enabled, run the process without real actions if (!this.option.betabeta) return this.getPlayerMenu(); // 3. Set up functions that are page static if (!videoRefresh) { this.retrieveUserdata(); if (this.option.badgeWatchLater) this.badgeWatchLater(); if (this.option.scroll) this.scrollToPlayer(); if (this.option.recommend) this.showRecommendTab(); if (this.option.autoResume) this.autoResume(); if (this.option.autoPlay) this.autoPlay(); if (this.option.autoWideScreen) this.autoWideScreen(); if (this.option.autoFullScreen) this.autoFullScreen(); if (this.option.focus) this.focusOnPlayer(); if (this.option.limitedKeydown) this.limitedKeydownFullScreenPlay(); if (this.option.series) this.inferNextInSeries(); this.playerWin.addEventListener('beforeunload', () => this.saveUserdata()); } // 4. Set up functions that are binded to the video DOM if (this.option.lift) this.liftBottomDanmuku(); if (this.option.dblclick) this.dblclickFullScreen(); if (this.option.electric) this.reallocateElectricPanel(); if (this.option.oped) this.skipOPED(); this.video.addEventListener('emptied', () => this.setFunctions({ videoRefresh: true })); // 5. Set up functions that require everything to be ready await this.getPlayerMenu(); if (this.option.menuFocus) this.menuFocusOnPlayer(); // 6. Set up experimental functions if (this.option.speech) top.document.body.addEventListener('click', e => e.detail > 2 && this.speechRecognition()); } async inferNextInSeries() { let title = top.document.getElementsByTagName('h1')[0].textContent.replace(/\(\d+\)$/, '').trim(); // 1. Find series name let epNumberText = title.match(/\d+/g); if (!epNumberText) return this.series = []; epNumberText = epNumberText.pop(); let seriesTitle = title.slice(0, title.lastIndexOf(epNumberText)).trim(); // 2. Substitude ep number let ep = parseInt(epNumberText); if (epNumberText === '09') ep = [`08`, `10`]; else if (epNumberText[0] === '0') ep = [`0${ep - 1}`, `0${ep + 1}`]; else ep = [`${ep - 1}`, `${ep + 1}`]; ep = [...ep.map(e => seriesTitle + e), ...ep]; let mid = top.document.getElementById('r-info-rank') || top.document.querySelector('.user'); if (!mid) return this.series = []; mid = mid.children[0].href.match(/\d+/)[0]; let vlist = await Promise.all([title, ...ep].map(keyword => new Promise((resolve, reject) => { let req = new XMLHttpRequest(); req.onload = () => resolve((req.response.status && req.response.data.vlist) || []); req.onerror = reject; req.open('get', `https://space.bilibili.com/ajax/member/getSubmitVideos?mid=${mid}&keyword=${keyword}`); req.responseType = 'json'; req.send(); }))); vlist[0] = [vlist[0].find(e => e.title == title)]; if (!vlist[0][0]) { console && console.warn('BiliPolyfill: inferNextInSeries: cannot find current video in mid space'); return this.series = []; } this.series = [vlist[1].find(e => e.created < vlist[0][0].created), vlist[2].reverse().find(e => e.created > vlist[0][0].created)]; if (!this.series[0]) this.series[0] = vlist[3].find(e => e.created < vlist[0][0].created) || null; if (!this.series[1]) this.series[1] = vlist[4].reverse().find(e => e.created > vlist[0][0].created) || null; return this.series; } badgeWatchLater() { let li = top.document.getElementById('i_menu_watchLater_btn') || top.document.getElementById('i_menu_later_btn'); if (!li || !li.children[1]) return; li.children[1].style.visibility = 'hidden'; li.dispatchEvent(new Event('mouseover')); let observer = new MutationObserver(() => { if (li.children[1].children[0].children[0].className == 'm-w-loading') return; observer.disconnect(); li.dispatchEvent(new Event('mouseout')); setTimeout(() => li.children[1].style.visibility = '', 700); if (li.children[1].children[0].children[0].className == 'no-data') return; let div = top.document.createElement('div'); div.className = 'num'; div.style.display = 'block'; div.style.left = 'initial'; div.style.right = '-6px'; if (li.children[1].children[0].children.length > 5) { div.textContent = '5+'; } else { div.textContent = li.children[1].children[0].children.length; } li.appendChild(div); }); observer.observe(li.children[1].children[0], { childList: true }); } dblclickFullScreen() { this.video.addEventListener('dblclick', () => this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen').click() ); } scrollToPlayer() { if (top.scrollY < 200) top.document.getElementById('bofqi').scrollIntoView(); } showRecommendTab() { let h = this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-filter-btn-recommend'); if (h) h.click(); } getCoverImage() { let ret = top.document.querySelector('.cover_image') || top.document.querySelector('div.info-cover > a > img') || top.document.querySelector('[data-state-play="true"] img') || top.document.querySelector('script[type="application/ld+json"]'); if (!ret) return null; ret = ret.src || JSON.parse(ret.textContent).images[0]; let i; i = ret.indexOf('.jpg'); if (i != -1) ret = ret.slice(0, i + 4); i = ret.indexOf('.png'); if (i != -1) ret = ret.slice(0, i + 4); return ret; } reallocateElectricPanel() { if (!this.playerWin.localStorage.bilibili_player_settings) return; if (!this.playerWin.localStorage.bilibili_player_settings.includes('"autopart":1') && !this.option.electricSkippable) return; this.video.addEventListener('ended', () => { setTimeout(() => { let i = this.playerWin.document.getElementsByClassName('bilibili-player-electric-panel')[0]; if (!i) return; i.children[2].click(); i.style.display = 'block'; i.style.zIndex = 233; let j = 5; let h = setInterval(() => { if (this.playerWin.document.getElementsByClassName('bilibili-player-video-toast-item-jump')[0]) i.style.zIndex = ''; if (j > 0) { i.children[2].children[0].textContent = `0${j}`; j--; } else { clearInterval(h); i.remove(); } }, 1000); }, 0); }); } liftBottomDanmuku() { // MUST initialize setting panel before click this.playerWin.document.getElementsByName('ctlbar_danmuku_close')[0].dispatchEvent(new Event('mouseover')); this.playerWin.document.getElementsByName('ctlbar_danmuku_close')[0].dispatchEvent(new Event('mouseout')); if (!this.playerWin.document.getElementsByName('ctlbar_danmuku_prevent')[0].nextSibling.className.includes('bpui-state-active')) this.playerWin.document.getElementsByName('ctlbar_danmuku_prevent')[0].click(); } loadOffineSubtitles() { // NO. NOBODY WILL NEED THIS。 // Hint: https://github.com/jamiees2/ass-to-vtt throw 'Not implemented'; } autoResume() { let h = () => { let span = this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-toast-bottom div.bilibili-player-video-toast-item-text span:nth-child(2)'); if (!span) return; let [min, sec] = span.textContent.split(':'); if (!min || !sec) return; let time = parseInt(min) * 60 + parseInt(sec); if (time < this.video.duration - 10) { if (!this.video.paused || this.video.autoplay) { this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-toast-bottom div.bilibili-player-video-toast-item-jump').click(); } else { let play = this.video.play; this.video.play = () => setTimeout(() => { this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-start').click(); this.video.play = play; }, 0); this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-toast-bottom div.bilibili-player-video-toast-item-jump').click(); } } else { this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-toast-bottom div.bilibili-player-video-toast-item-close').click(); this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-toast-bottom').children[0].style.visibility = 'hidden'; } }; this.video.addEventListener('canplay', h); setTimeout(() => this.video && this.video.removeEventListener && this.video.removeEventListener('canplay', h), 3000); } autoPlay() { this.video.autoplay = true; setTimeout(() => { if (this.video.paused) this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-start').click() }, 0); } autoWideScreen() { if (this.playerWin.document.querySelector('#bilibiliPlayer i.icon-24wideoff')) this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-widescreen').click(); } autoFullScreen() { if (this.playerWin.document.querySelector('#bilibiliPlayer div.video-state-fullscreen-off')) this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen').click(); } getCollectionId() { return (top.location.pathname.match(/av\d+/) || top.location.hash.match(/av\d+/) || top.document.querySelector('div.bangumi-info a').href).toString(); } markOPEDPosition(index) { let collectionId = this.getCollectionId(); if (!Array.isArray(this.userdata.oped[collectionId])) this.userdata.oped[collectionId] = []; this.userdata.oped[collectionId][index] = this.video.currentTime; } clearOPEDPosition() { let collectionId = this.getCollectionId(); this.userdata.oped[collectionId] = undefined; } skipOPED() { let collectionId = this.getCollectionId(); if (!Array.isArray(this.userdata.oped[collectionId]) || !this.userdata.oped[collectionId].length) return; /** * structure: * listen for time update -> || <- skip -> || <- remove event listenner */ if (!this.userdata.oped[collectionId][0] && this.userdata.oped[collectionId][1]) { let h = () => { if (this.video.currentTime >= this.userdata.oped[collectionId][1] - 1) { this.video.removeEventListener('timeupdate', h); } else { this.video.currentTime = this.userdata.oped[collectionId][1]; this.hintInfo('BiliPolyfill: 已跳过片头'); } } this.video.addEventListener('timeupdate', h); } if (this.userdata.oped[collectionId][0] && this.userdata.oped[collectionId][1]) { let h = () => { if (this.video.currentTime >= this.userdata.oped[collectionId][1] - 1) { this.video.removeEventListener('timeupdate', h); } else if (this.video.currentTime > this.userdata.oped[collectionId][0]) { this.video.currentTime = this.userdata.oped[collectionId][1]; this.hintInfo('BiliPolyfill: 已跳过片头'); } } this.video.addEventListener('timeupdate', h); } if (this.userdata.oped[collectionId][2] && !this.userdata.oped[collectionId][3]) { let h = () => { if (this.video.currentTime >= this.video.duration - 1) { this.video.removeEventListener('timeupdate', h); } else if (this.video.currentTime > this.userdata.oped[collectionId][2]) { this.video.currentTime = this.video.duration; this.hintInfo('BiliPolyfill: 已跳过片尾'); } } this.video.addEventListener('timeupdate', h); } if (this.userdata.oped[collectionId][2] && this.userdata.oped[collectionId][3]) { let h = () => { if (this.video.currentTime >= this.userdata.oped[collectionId][3] - 1) { this.video.removeEventListener('timeupdate', h); } else if (this.video.currentTime > this.userdata.oped[collectionId][2]) { this.video.currentTime = this.userdata.oped[collectionId][3]; this.hintInfo('BiliPolyfill: 已跳过片尾'); } } this.video.addEventListener('timeupdate', h); } } setVideoSpeed(speed) { if (speed < 0 || speed > 10) return; this.video.playbackRate = speed; } focusOnPlayer() { this.playerWin.document.getElementsByClassName('bilibili-player-video-progress')[0].click(); } menuFocusOnPlayer() { this.playerWin.document.getElementsByClassName('bilibili-player-context-menu-container black')[0].addEventListener('click', () => setTimeout(() => this.focusOnPlayer(), 0)); } limitedKeydownFullScreenPlay() { let h = e => { if (!e.isTrusted) return; if (e.key == 'Enter') { if (this.playerWin.document.querySelector('#bilibiliPlayer div.video-state-fullscreen-off')) { this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen').click(); } if (this.video.paused) { if (this.video.readyState) { this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-start').click(); } else { let i = () => { this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-start').click(); this.video.removeEventListener('canplay', i); } this.video.addEventListener('canplay', i); } } } top.document.removeEventListener('keydown', h); top.document.removeEventListener('click', h); }; top.document.addEventListener('keydown', h); top.document.addEventListener('click', h); } speechRecognition() { const SpeechRecognition = top.SpeechRecognition || top.webkitSpeechRecognition; const SpeechGrammarList = top.SpeechGrammarList || top.webkitSpeechGrammarList; alert('Yahaha! You found me!\nBiliTwin支持的语音命令: 播放 暂停 全屏 关闭 加速 减速 下一集\nChrome may support Cantonese or Hakka as well. See BiliPolyfill::speechRecognition.'); if (!SpeechRecognition || !SpeechGrammarList) alert('浏览器太旧啦~彩蛋没法运行~'); let player = ['播放', '暂停', '全屏', '关闭', '加速', '减速', '下一集']; let grammar = '#JSGF V1.0; grammar player; public = ' + player.join(' | ') + ' ;'; let recognition = new SpeechRecognition(); let speechRecognitionList = new SpeechGrammarList(); speechRecognitionList.addFromString(grammar, 1); recognition.grammars = speechRecognitionList; // cmn: Mandarin(Putonghua), yue: Cantonese, hak: Hakka // See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry recognition.lang = 'cmn'; recognition.continuous = true; recognition.interimResults = false; recognition.maxAlternatives = 1; recognition.start(); recognition.onresult = e => { let last = e.results.length - 1; let transcript = e.results[last][0].transcript; switch (transcript) { case '播放': if (this.video.paused) this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-start').click(); this.hintInfo(`BiliPolyfill: 语音:播放`); break; case '暂停': if (!this.video.paused) this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-start').click(); this.hintInfo(`BiliPolyfill: 语音:暂停`); break; case '全屏': this.playerWin.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen').click(); this.hintInfo(`BiliPolyfill: 语音:全屏`); break; case '关闭': top.close(); break; case '加速': this.setVideoSpeed(2); this.hintInfo(`BiliPolyfill: 语音:加速`); break; case '减速': this.setVideoSpeed(0.5); this.hintInfo(`BiliPolyfill: 语音:减速`); break; case '下一集': this.video.dispatchEvent(new Event('ended')); default: this.hintInfo(`BiliPolyfill: 语音:"${transcript}"?`); break; } typeof console == "object" && console.log(e.results); typeof console == "object" && console.log(`transcript:${transcript} confidence:${e.results[0][0].confidence}`); }; } substitudeFullscreenPlayer(option) { if (!option) throw 'usage: substitudeFullscreenPlayer({cid, aid[, p][, ...otherOptions]})'; if (!option.cid) throw 'player init: cid missing'; if (!option.aid) throw 'player init: aid missing'; let h = this.playerWin.document; let i = [h.webkitExitFullscreen, h.mozExitFullScreen, h.msExitFullscreen, h.exitFullscreen]; h.webkitExitFullscreen = h.mozExitFullScreen = h.msExitFullscreen = h.exitFullscreen = () => { }; this.playerWin.player.destroy(); this.playerWin.player = new bilibiliPlayer(option); if (option.p) this.playerWin.callAppointPart(option.p); [h.webkitExitFullscreen, h.mozExitFullScreen, h.msExitFullscreen, h.exitFullscreen] = i; } async getPlayerVideo() { if (this.playerWin.document.getElementsByTagName('video').length) { return this.video = this.playerWin.document.getElementsByTagName('video')[0]; } else { return new Promise(resolve => { let observer = new MutationObserver(() => { if (this.playerWin.document.getElementsByTagName('video').length) { observer.disconnect(); resolve(this.video = this.playerWin.document.getElementsByTagName('video')[0]); } }); observer.observe(this.playerWin.document.getElementById('bilibiliPlayer'), { childList: true }); }); } } async getPlayerMenu() { if (this.playerWin.document.getElementsByClassName('bilibili-player-context-menu-container black').length) { return this.playerWin.document.getElementsByClassName('bilibili-player-context-menu-container black')[0]; } else { return new Promise(resolve => { let observer = new MutationObserver(() => { if (this.playerWin.document.getElementsByClassName('bilibili-player-context-menu-container black').length) { observer.disconnect(); resolve(this.playerWin.document.getElementsByClassName('bilibili-player-context-menu-container black')[0]); } }); observer.observe(this.playerWin.document.getElementById('bilibiliPlayer'), { childList: true }); }); } } static async openMinimizedPlayer(option = { cid: top.cid, aid: top.aid, playerWin: top }) { if (!option) throw 'usage: openMinimizedPlayer({cid[, aid]})'; if (!option.cid) throw 'player init: cid missing'; if (!option.aid) option.aid = top.aid; if (!option.playerWin) option.playerWin = top; let h = top.open(`//www.bilibili.com/blackboard/html5player.html?cid=${option.cid}&aid=${option.aid}&crossDomain=${top.document.domain != 'www.bilibili.com' ? 'true' : ''}`, undefined, ' '); let res = top.location.href.includes('bangumi') && await new Promise(resolve => { const jq = option.playerWin.jQuery; const _ajax = jq.ajax; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { a.success = resolve; jq.ajax = _ajax; } return _ajax.call(jq, a, c); }; option.playerWin.player.reloadAccess(); }); await new Promise(resolve => { let i = setInterval(() => h.document.getElementById('bilibiliPlayer') && resolve(), 500); h.addEventListener('load', resolve); setTimeout(() => { clearInterval(i); h.removeEventListener('load', resolve); resolve(); }, 6000); }); let div = h.document.getElementById('bilibiliPlayer'); if (!div) { console.warn('openMinimizedPlayer: document load timeout'); return; } if (res) { await new Promise(resolve => { const jq = h.jQuery; const _ajax = jq.ajax; jq.ajax = function (a, c) { if (typeof c === 'object') { if (typeof a === 'string') c.url = a; a = c; c = undefined }; if (a.url.includes('interface.bilibili.com/v2/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { a.success(res) jq.ajax = _ajax; resolve(); } else { return _ajax.call(jq, a, c); } }; h.player = new h.bilibiliPlayer({ cid: option.cid, aid: option.aid }); // h.eval(`player = new bilibiliPlayer({ cid: ${option.cid}, aid: ${option.aid} })`); // console.log(`player = new bilibiliPlayer({ cid: ${option.cid}, aid: ${option.aid} })`); }) } await new Promise(resolve => { if (h.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen')) resolve(); else { let observer = new MutationObserver(() => { if (h.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen')) { observer.disconnect(); resolve(); } }); observer.observe(h.document.getElementById('bilibiliPlayer'), { childList: true }); } }); let i = [div.webkitRequestFullscreen, div.mozRequestFullScreen, div.msRequestFullscreen, div.requestFullscreen]; div.webkitRequestFullscreen = div.mozRequestFullScreen = div.msRequestFullscreen = div.requestFullscreen = () => { }; if (h.document.querySelector('#bilibiliPlayer div.video-state-fullscreen-off')) h.document.querySelector('#bilibiliPlayer div.bilibili-player-video-btn-fullscreen').click(); [div.webkitRequestFullscreen, div.mozRequestFullScreen, div.msRequestFullscreen, div.requestFullscreen] = i; } static parseHref(href = top.location.href) { if (href.includes('bangumi')) { let anime, play; anime = (anime = /anime\/\d+/.exec(href)) ? anime[0].slice(6) : null; play = (play = /play#\d+/.exec(href)) ? play[0].slice(5) : null; if (!anime || !play) return null; return `bangumi.bilibili.com/anime/${anime}/play#${play}`; } else { let aid, pid; aid = (aid = /av\d+/.exec(href)) ? aid[0].slice(2) : null; if (!aid) return null; pid = (pid = /page=\d+/.exec(href)) ? pid[0].slice(5) : (pid = /index_\d+.html/.exec(href)) ? pid[0].slice(6, -5) : null; if (!pid) return `www.bilibili.com/video/av${aid}`; return `www.bilibili.com/video/av${aid}/index_${pid}.html`; } } static secondToReadable(s) { if (s > 60) return `${parseInt(s / 60)}分${parseInt(s % 60)}秒`; else return `${parseInt(s % 60)}秒`; } static clearAllUserdata(playerWin = top) { if (playerWin.GM_setValue) return GM_setValue('biliPolyfill', ''); playerWin.localStorage.removeItem('biliPolyfill'); } static _UNIT_TEST() { console.warn('This test is impossible.'); console.warn('You need to close the tab, reopen it, etc.'); console.warn('Maybe you also want to test between bideo parts, etc.'); console.warn('I am too lazy to find workarounds.'); } } class BiliUserJS { static async getIframeWin() { if (document.querySelector('#bofqi > iframe').contentDocument.getElementById('bilibiliPlayer')) { return document.querySelector('#bofqi > iframe').contentWindow; } else { return new Promise(resolve => { document.querySelector('#bofqi > iframe').addEventListener('load', () => { resolve(document.querySelector('#bofqi > iframe').contentWindow); }); }); } } static async getPlayerWin() { if (location.href.includes('/watchlater/#/list')) { await new Promise(resolve => { let h = () => { resolve(location.href); window.removeEventListener('hashchange', h); }; window.addEventListener('hashchange', h) }); } if (location.href.includes('/watchlater/#/')) { if (!document.getElementById('bofqi')) { await new Promise(resolve => { let observer = new MutationObserver(() => { if (document.getElementById('bofqi')) { resolve(document.getElementById('bofqi')); observer.disconnect(); } }); observer.observe(document, { childList: true, subtree: true }); }); } } if (document.getElementById('bilibiliPlayer')) { return window; } else if (document.querySelector('#bofqi > iframe')) { return BiliUserJS.getIframeWin(); } else if (document.querySelector('#bofqi > object')) { throw 'Need H5 Player'; } else { return new Promise(resolve => { let observer = new MutationObserver(() => { if (document.getElementById('bilibiliPlayer')) { observer.disconnect(); resolve(window); } else if (document.querySelector('#bofqi > iframe')) { observer.disconnect(); resolve(BiliUserJS.getIframeWin()); } else if (document.querySelector('#bofqi > object')) { observer.disconnect(); throw 'Need H5 Player'; } }); observer.observe(document.getElementById('bofqi'), { childList: true }); }) } } } class UI extends BiliUserJS { // Title Append static titleAppend(monkey) { const tminfo = document.querySelector('div.tminfo') || document.querySelector('div.info-second'); let div = document.createElement('div'); let flvA = document.createElement('a'); let mp4A = document.createElement('a'); let assA = document.createElement('a'); flvA.textContent = '超清FLV'; mp4A.textContent = '原生MP4'; assA.textContent = '弹幕ASS'; flvA.onmouseover = async () => { flvA.textContent = '正在FLV'; flvA.onmouseover = null; let href = await monkey.queryInfo('flv'); if (href == 'does_not_exist') return flvA.textContent = '没有FLV'; flvA.textContent = '超清FLV'; let flvDiv = UI.genFLVDiv(monkey); document.body.appendChild(flvDiv); flvA.onclick = () => flvDiv.style.display = 'block'; }; mp4A.onmouseover = async () => { mp4A.textContent = '正在MP4'; mp4A.onmouseover = null; let href = await monkey.queryInfo('mp4'); if (href == 'does_not_exist') return mp4A.textContent = '没有MP4'; mp4A.href = href; mp4A.textContent = '原生MP4'; mp4A.download = ''; mp4A.referrerPolicy = 'origin'; }; assA.onmouseover = async () => { assA.textContent = '正在ASS'; assA.onmouseover = null; assA.href = await monkey.queryInfo('ass'); assA.textContent = '弹幕ASS'; if (monkey.mp4 && monkey.mp4.match) assA.download = monkey.mp4.match(/\d(?:\d|-|hd)*(?=\.mp4)/)[0] + '.ass'; else assA.download = monkey.cid + '.ass'; }; div.addEventListener('click', e => e.stopPropagation()); flvA.style.fontSize = mp4A.style.fontSize = assA.style.fontSize = '15px'; div.appendChild(flvA); div.appendChild(document.createTextNode(' ')); div.appendChild(mp4A); div.appendChild(document.createTextNode(' ')); div.appendChild(assA); div.className = 'bilitwin'; div.style.float = 'left'; tminfo.style.float = 'none'; tminfo.style.marginLeft = '185px'; tminfo.parentElement.insertBefore(div, tminfo); return { flvA, mp4A, assA }; } static genFLVDiv(monkey, flvs = monkey.flvs, cache = monkey.cache) { let div = UI.genDiv(); let table = document.createElement('table'); table.style.width = '100%'; table.style.lineHeight = '2em'; for (let i = 0; i < flvs.length; i++) { let tr = table.insertRow(-1); tr.insertCell(0).innerHTML = `FLV分段 ${i + 1}`; tr.insertCell(1).innerHTML = '缓存本段'; tr.insertCell(2).innerHTML = '进度条'; tr.children[1].children[0].onclick = () => { UI.downloadFLV(tr.children[1].children[0], monkey, i, tr.children[2].children[0]); } } let tr = table.insertRow(-1); tr.insertCell(0).innerHTML = '全部复制到剪贴板'; tr.insertCell(1).innerHTML = '缓存全部+自动合并'; tr.insertCell(2).innerHTML = `进度条`; if (top.location.href.includes('bangumi')) { tr.children[0].children[0].onclick = () => UI.copyToClipboard(flvs.join('\n')); } else { tr.children[0].innerHTML = 'IDM导出'; tr.children[0].children[0].href = URL.createObjectURL(new Blob([UI.exportIDM(flvs, top.location.origin)])); } tr.children[1].children[0].onclick = () => UI.downloadAllFLVs(tr.children[1].children[0], monkey, table); table.insertRow(-1).innerHTML = '合并功能推荐配置:至少8G RAM。把自己下载的分段FLV拖动到这里,也可以合并哦~'; table.insertRow(-1).innerHTML = cache ? '下载的缓存分段会暂时停留在电脑里,过一段时间会自动消失。建议只开一个标签页。' : '建议只开一个标签页。关掉标签页后,缓存就会被清理。别忘了另存为!'; UI.displayQuota(table.insertRow(-1)); div.appendChild(table); div.ondragenter = div.ondragover = e => UI.allowDrag(e); div.ondrop = async e => { UI.allowDrag(e); let files = Array.from(e.dataTransfer.files); if (files.every(e => e.name.search(/\d+-\d+(?:-\d+)?\.flv/) != -1)) { files.sort((a, b) => a.name.match(/\d+-(\d+)(?:-\d+)?\.flv/)[1] - b.name.match(/\d+-(\d+)(?:-\d+)?\.flv/)[1]); } for (let file of files) { table.insertRow(-1).innerHTML = `${file.name}`; } let outputName = files[0].name.match(/\d+-\d+(?:-\d+)?\.flv/); if (outputName) outputName = outputName[0].replace(/-\d/, ""); else outputName = 'merge_' + files[0].name; let url = await UI.mergeFLVFiles(files); table.insertRow(-1).innerHTML = `${outputName}`; } let buttons = []; for (let i = 0; i < 3; i++) buttons.push(document.createElement('button')); buttons.forEach(btn => btn.style.padding = '0.5em'); buttons.forEach(btn => btn.style.margin = '0.2em'); buttons[0].textContent = '关闭'; buttons[0].onclick = () => { div.style.display = 'none'; } buttons[1].textContent = '清空这个视频的缓存'; buttons[1].onclick = () => { monkey.cleanAllFLVsInCache(); } buttons[2].textContent = '清空所有视频的缓存'; buttons[2].onclick = () => { UI.clearCacheDB(cache); } buttons.forEach(btn => div.appendChild(btn)); return div; } static async downloadAllFLVs(a, monkey, table) { if (table.rows[0].cells.length < 3) return; monkey.hangPlayer(); table.insertRow(-1).innerHTML = '已屏蔽网页播放器的网络链接。切换清晰度可重新激活播放器。'; for (let i = 0; i < monkey.flvs.length; i++) { if (table.rows[i].cells[1].children[0].textContent == '缓存本段') table.rows[i].cells[1].children[0].click(); } let bar = a.parentNode.nextSibling.children[0]; bar.max = monkey.flvs.length + 1; bar.value = 0; for (let i = 0; i < monkey.flvs.length; i++) monkey.getFLV(i).then(e => bar.value++); let blobs; blobs = await monkey.getAllFLVs(); let mergedFLV = await FLV.mergeBlobs(blobs); let ass = await monkey.ass; let url = URL.createObjectURL(mergedFLV); let outputName = top.document.getElementsByTagName('h1')[0].textContent.trim(); bar.value++; table.insertRow(0).innerHTML = ` 保存合并后FLV 弹幕ASS 打包MKV(软字幕封装) 记得清理分段缓存哦~ `; table.rows[0].cells[0].children[2].onclick = () => new MKVTransmuxer().exec(url, ass, `${outputName}.mkv`); return url; } static async downloadFLV(a, monkey, index, bar = {}) { let handler = e => UI.beforeUnloadHandler(e); window.addEventListener('beforeunload', handler); a.textContent = '取消'; a.onclick = () => { a.onclick = null; window.removeEventListener('beforeunload', handler); a.textContent = '已取消'; monkey.abortFLV(index); }; let url; try { url = await monkey.getFLV(index, (loaded, total) => { bar.value = loaded; bar.max = total; }); url = URL.createObjectURL(url); if (bar.value == 0) bar.value = bar.max = 1; } catch (e) { a.onclick = null; window.removeEventListener('beforeunload', handler); a.textContent = '错误'; throw e; } a.onclick = null; window.removeEventListener('beforeunload', handler); a.textContent = '另存为'; a.download = monkey.flvs[index].match(/\d+-\d+(?:-\d+)?\.flv/)[0]; a.href = url; return url; } static async mergeFLVFiles(files) { let merged = await FLV.mergeBlobs(files) return URL.createObjectURL(merged); } static async clearCacheDB(cache) { if (cache) return cache.deleteEntireDB(); } static async displayQuota(tr) { return new Promise(resolve => { let temporaryStorage = window.navigator.temporaryStorage || window.navigator.webkitTemporaryStorage || window.navigator.mozTemporaryStorage || window.navigator.msTemporaryStorage; if (!temporaryStorage) return resolve(tr.innerHTML = `这个浏览器不支持缓存呢~关掉标签页后,缓存马上就会消失哦`); temporaryStorage.queryUsageAndQuota((usage, quota) => resolve(tr.innerHTML = `缓存已用空间:${Math.round(usage / 1048576)}MB / ${Math.round(quota / 1048576)}MB 也包括了B站本来的缓存`) ); }); } // Menu Append static menuAppend(playerWin, { monkey, monkeyTitle, polyfill, displayPolyfillDataDiv, optionDiv }) { let monkeyMenu = UI.genMonkeyMenu(playerWin, { monkey, monkeyTitle, optionDiv }); let polyfillMenu = UI.genPolyfillMenu(playerWin, { polyfill, displayPolyfillDataDiv, optionDiv }); let div = playerWin.document.getElementsByClassName('bilibili-player-context-menu-container black')[0]; let ul = playerWin.document.createElement('ul'); ul.className = 'bilitwin'; ul.style.borderBottom = '1px solid rgba(255,255,255,.12)'; div.insertBefore(ul, div.children[0]); ul.appendChild(monkeyMenu); ul.appendChild(polyfillMenu); } static genMonkeyMenu(playerWin, { monkey, monkeyTitle, optionDiv }) { let li = playerWin.document.createElement('li'); li.className = 'context-menu-menu bilitwin'; li.innerHTML = ` BiliMonkey `; li.onclick = () => playerWin.document.getElementById('bilibiliPlayer').click(); let ul = li.children[1]; ul.children[0].onclick = async () => { if (monkeyTitle.flvA.onmouseover) await monkeyTitle.flvA.onmouseover(); monkeyTitle.flvA.click(); }; ul.children[1].onclick = async () => { if (monkeyTitle.mp4A.onmouseover) await monkeyTitle.mp4A.onmouseover(); monkeyTitle.mp4A.click(); }; ul.children[2].onclick = async () => { if (monkeyTitle.assA.onmouseover) await monkeyTitle.assA.onmouseover(); monkeyTitle.assA.click(); }; ul.children[3].onclick = () => { optionDiv.style.display = 'block'; }; ul.children[4].onclick = async () => { await BiliMonkey.getAllPageDefaultFormats(playerWin) }; ul.children[5].onclick = async () => { monkey.proxy = true; monkey.flvs = null; UI.hintInfo('请稍候,可能需要10秒时间……', playerWin); // Yes, I AM lazy. playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul li[data-value="80"]').click(); await new Promise(r => playerWin.document.getElementsByTagName('video')[0].addEventListener('emptied', r)); return monkey.queryInfo('flv'); }; ul.children[6].onclick = () => { top.location.reload(true); }; ul.children[7].onclick = () => { playerWin.dispatchEvent(new Event('unload')); }; ul.children[8].onclick = () => { playerWin.player && playerWin.player.destroy() }; return li; } static genPolyfillMenu(playerWin, { polyfill, displayPolyfillDataDiv, optionDiv }) { let li = playerWin.document.createElement('li'); li.className = 'context-menu-menu bilitwin'; li.innerHTML = ` BiliPolyfill `; li.onclick = () => playerWin.document.getElementById('bilibiliPlayer').click(); if (!polyfill.option.betabeta) li.children[0].childNodes[0].textContent += '(到设置开启)'; let ul = li.children[1]; ul.children[0].onclick = () => { top.window.open(polyfill.getCoverImage(), '_blank'); }; ul.children[1].children[1].children[0].onclick = () => { polyfill.setVideoSpeed(0.1); }; ul.children[1].children[1].children[1].onclick = () => { polyfill.setVideoSpeed(3); }; ul.children[1].children[1].children[2].onclick = e => { polyfill.setVideoSpeed(e.target.getElementsByTagName('input')[0].value); }; ul.children[1].children[1].children[2].getElementsByTagName('input')[0].onclick = e => e.stopPropagation(); ul.children[2].children[1].children[0].onclick = () => { polyfill.markOPEDPosition(0); }; ul.children[2].children[1].children[1].onclick = () => { polyfill.markOPEDPosition(1); }; ul.children[2].children[1].children[2].onclick = () => { polyfill.markOPEDPosition(2); }; ul.children[2].children[1].children[3].onclick = () => { polyfill.markOPEDPosition(3); }; ul.children[2].children[1].children[4].onclick = () => { polyfill.clearOPEDPosition(); }; ul.children[2].children[1].children[5].onclick = () => { displayPolyfillDataDiv(polyfill); }; ul.children[3].children[1].children[0].getElementsByTagName('a')[0].style.width = 'initial'; ul.children[3].children[1].children[1].getElementsByTagName('a')[0].style.width = 'initial'; ul.children[4].onclick = () => { BiliPolyfill.openMinimizedPlayer(); }; ul.children[5].onclick = () => { optionDiv.style.display = 'block'; }; ul.children[6].onclick = () => { polyfill.saveUserdata() }; ul.children[7].onclick = () => { BiliPolyfill.clearAllUserdata(playerWin); polyfill.retrieveUserdata(); }; li.onmouseenter = () => { let ul = li.children[1]; ul.children[1].children[1].children[2].getElementsByTagName('input')[0].value = polyfill.video.playbackRate; let oped = polyfill.userdata.oped[polyfill.getCollectionId()] || []; ul.children[2].children[1].children[0].getElementsByTagName('span')[1].textContent = oped[0] ? BiliPolyfill.secondToReadable(oped[0]) : '无'; ul.children[2].children[1].children[1].getElementsByTagName('span')[1].textContent = oped[1] ? BiliPolyfill.secondToReadable(oped[1]) : '无'; ul.children[2].children[1].children[2].getElementsByTagName('span')[1].textContent = oped[2] ? BiliPolyfill.secondToReadable(oped[2]) : '无'; ul.children[2].children[1].children[3].getElementsByTagName('span')[1].textContent = oped[3] ? BiliPolyfill.secondToReadable(oped[3]) : '无'; ul.children[3].children[1].children[0].onclick = () => { if (polyfill.series[0]) top.window.open(`https://www.bilibili.com/video/av${polyfill.series[0].aid}`, '_blank'); }; ul.children[3].children[1].children[1].onclick = () => { if (polyfill.series[1]) top.window.open(`https://www.bilibili.com/video/av${polyfill.series[1].aid}`, '_blank'); }; ul.children[3].children[1].children[0].getElementsByTagName('span')[1].textContent = polyfill.series[0] ? polyfill.series[0].title : '找不到'; ul.children[3].children[1].children[1].getElementsByTagName('span')[1].textContent = polyfill.series[1] ? polyfill.series[1].title : '找不到'; } return li; } static genOptionDiv(option) { let div = UI.genDiv(); div.appendChild(UI.genMonkeyOptionTable(option)); div.appendChild(UI.genPolyfillOptionTable(option)); let table = document.createElement('table'); table.style = 'width: 100%; line-height: 2em;'; table.insertRow(-1).innerHTML = '设置自动保存,刷新后生效。'; table.insertRow(-1).innerHTML = '视频下载组件的缓存功能只在Windows+Chrome测试过,如果出现问题,请关闭缓存。'; table.insertRow(-1).innerHTML = '功能增强组件尽量保证了兼容性。但如果有同功能脚本/插件,请关闭本插件的对应功能。'; table.insertRow(-1).innerHTML = '这个脚本乃“按原样”提供,不附带任何明示,暗示或法定的保证,包括但不限于其没有缺陷,适合特定目的或非侵权。'; table.insertRow(-1).innerHTML = '更新/讨论 GitHub Author: qli5. Copyright: qli5, 2014+, 田生, grepmusic'; div.appendChild(table); let buttons = []; for (let i = 0; i < 3; i++) buttons.push(document.createElement('button')); buttons.forEach(btn => btn.style.padding = '0.5em'); buttons.forEach(btn => btn.style.margin = '0.2em'); buttons[0].textContent = '保存并关闭'; buttons[0].onclick = () => { div.style.display = 'none';; } buttons[1].textContent = '保存并刷新'; buttons[1].onclick = () => { top.location.reload(); } buttons[2].textContent = '重置并刷新'; buttons[2].onclick = () => { UI.saveOption({ setStorage: option.setStorage }); top.location.reload(); } buttons.forEach(btn => div.appendChild(btn)); return div; } static genMonkeyOptionTable(option = {}) { const description = [ ['autoDefault', '尝试自动抓取:不会拖慢页面,抓取默认清晰度,但可能抓不到。'], ['autoFLV', '强制自动抓取FLV:会拖慢页面,如果默认清晰度也是超清会更慢,但保证抓到。'], ['autoMP4', '强制自动抓取MP4:会拖慢页面,如果默认清晰度也是高清会更慢,但保证抓到。'], ['cache', '关标签页不清缓存:保留完全下载好的分段到缓存,忘记另存为也没关系。'], ['partial', '断点续传:点击“取消”保留部分下载的分段到缓存,忘记点击会弹窗确认。'], ['proxy', '用缓存加速播放器:如果缓存里有完全下载好的分段,直接喂给网页播放器,不重新访问网络。小水管利器,播放只需500k流量。如果实在搞不清怎么播放ASS弹幕,也可以就这样用。'], ['blocker', '弹幕过滤:在网页播放器里设置的屏蔽词也对下载的弹幕生效。'], ['font', '自定义字体:在网页播放器里设置的字体、大小、加粗、透明度也对下载的弹幕生效。'] ]; let table = document.createElement('table'); table.style.width = '100%'; table.style.lineHeight = '2em'; table.insertRow(-1).innerHTML = 'BiliMonkey(视频抓取组件)'; table.insertRow(-1).innerHTML = '因为作者偷懒了,缓存的三个选项最好要么全开,要么全关。最好。'; for (let d of description) { let checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = option[d[0]]; checkbox.onchange = () => { option[d[0]] = checkbox.checked; UI.saveOption(option); }; let td = table.insertRow(-1).insertCell(0); let label = document.createElement('label'); label.appendChild(checkbox); label.appendChild(document.createTextNode(d[1])); td.appendChild(label); } return table; } static genPolyfillOptionTable(option = {}) { const description = [ ['betabeta', '增强组件总开关 <---------更加懒得测试了,反正以后B站也会自己提供这些功能。也许吧。'], //betabeta ['badgeWatchLater', '稍后再看添加数字角标'], ['dblclick', '双击全屏'], ['scroll', '自动滚动到播放器'], ['recommend', '弹幕列表换成相关视频'], ['electric', '整合充电榜与换P倒计时'], ['electricSkippable', '跳过充电榜', 'disabled'], ['lift', '自动防挡字幕'], ['autoResume', '自动跳转上次看到'], ['autoPlay', '自动播放'], ['autoWideScreen', '自动宽屏'], ['autoFullScreen', '自动全屏'], ['oped', '标记后自动跳OP/ED'], ['focus', '自动聚焦到播放器'], ['menuFocus', '关闭菜单后聚焦到播放器'], ['limitedKeydown', '首次回车键可全屏自动播放'], ['series', '尝试自动找上下集'], ['speech', '(测)(需墙外)任意三击鼠标左键开启语音识别'], ]; let table = document.createElement('table'); table.style.width = '100%'; table.style.lineHeight = '2em'; table.insertRow(-1).innerHTML = 'BiliPolyfill(功能增强组件)'; table.insertRow(-1).innerHTML = '懒鬼作者还在测试的时候,B站已经上线了原生的稍后再看(๑•̀ㅂ•́)و✧'; for (let d of description) { let checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = option[d[0]]; checkbox.onchange = () => { option[d[0]] = checkbox.checked; UI.saveOption(option); }; let td = table.insertRow(-1).insertCell(0); let label = document.createElement('label'); label.appendChild(checkbox); label.appendChild(document.createTextNode(d[1])); if (d[2] == 'disabled') { checkbox.disabled = true; label.style.textDecoration = 'line-through'; } td.appendChild(label); } return table; } static displayPolyfillDataDiv(polyfill) { let div = UI.genDiv(); let p = document.createElement('p'); p.textContent = '这里是脚本储存的数据。所有数据都只存在浏览器里,别人不知道,B站也不知道,脚本作者更不知道(这个家伙连服务器都租不起 摔'; p.style.margin = '0.3em'; div.appendChild(p); let textareas = []; for (let i = 0; i < 2; i++) textareas.push(document.createElement('textarea')); textareas.forEach(ta => ta.style = 'resize:vertical; width: 100%; height: 200px'); p = document.createElement('p'); p.textContent = 'B站已上线原生的稍后观看功能。'; p.style.margin = '0.3em'; div.appendChild(p); //textareas[0].textContent = JSON.stringify(polyfill.userdata.watchLater).replace(/\[/, '[\n').replace(/\]/, '\n]').replace(/,/g, ',\n'); //div.appendChild(textareas[0]); p = document.createElement('p'); p.textContent = '这里是片头片尾。格式是,av号或番剧号:[片头开始(默认=0),片头结束(默认=不跳),片尾开始(默认=不跳),片尾结束(默认=无穷大)]。可以任意填写null,脚本会自动采用默认值。'; p.style.margin = '0.3em'; div.appendChild(p); textareas[1].textContent = JSON.stringify(polyfill.userdata.oped).replace(/{/, '{\n').replace(/}/, '\n}').replace(/],/g, '],\n'); div.appendChild(textareas[1]); p = document.createElement('p'); p.textContent = '当然可以直接清空啦。只删除其中的一些行的话,一定要记得删掉多余的逗号。'; p.style.margin = '0.3em'; div.appendChild(p); let buttons = []; for (let i = 0; i < 3; i++) buttons.push(document.createElement('button')); buttons.forEach(btn => btn.style.padding = '0.5em'); buttons.forEach(btn => btn.style.margin = '0.2em'); buttons[0].textContent = '关闭'; buttons[0].onclick = () => { div.remove(); } buttons[1].textContent = '验证格式'; buttons[1].onclick = () => { if (!textareas[0].value) textareas[0].value = '{\n\n}'; textareas[0].value = textareas[0].value.replace(/,(\s|\n)*}/, '\n}').replace(/,(\s|\n),/g, ',\n'); if (!textareas[1].value) textareas[1].value = '{\n\n}'; textareas[1].value = textareas[1].value.replace(/,(\s|\n)*}/, '\n}').replace(/,(\s|\n),/g, ',\n').replace(/,(\s|\n)*]/g, ']'); let userdata = {}; try { //userdata.watchLater = JSON.parse(textareas[0].value); } catch (e) { alert('稍后观看列表: ' + e); throw e; } try { userdata.oped = JSON.parse(textareas[1].value); } catch (e) { alert('片头片尾: ' + e); throw e; } buttons[1].textContent = ('格式没有问题!'); return userdata; } buttons[2].textContent = '尝试保存'; buttons[2].onclick = () => { polyfill.userdata = buttons[1].onclick(); polyfill.saveUserdata(); buttons[2].textContent = ('保存成功'); } buttons.forEach(btn => div.appendChild(btn)); document.body.appendChild(div); div.style.display = 'block'; } // Common static genDiv() { let div = document.createElement('div'); div.style.position = 'fixed'; div.style.zIndex = '10036'; div.style.top = '50%'; div.style.marginTop = '-200px'; div.style.left = '50%'; div.style.marginLeft = '-320px'; div.style.width = '540px'; div.style.maxHeight = '400px'; div.style.overflowY = 'auto'; div.style.padding = '30px 50px'; div.style.backgroundColor = 'white'; div.style.borderRadius = '6px'; div.style.boxShadow = 'rgba(0, 0, 0, 0.6) 1px 1px 40px 0px'; div.style.display = 'none'; div.className = 'bilitwin'; div.addEventListener('click', e => e.stopPropagation()); return div; } static requestH5Player() { let h = document.querySelector('div.tminfo'); h.insertBefore(document.createTextNode('[[脚本需要HTML5播放器(弹幕列表右上角三个点的按钮切换)]] '), h.firstChild); } static copyToClipboard(text) { let textarea = document.createElement('textarea'); document.body.appendChild(textarea); textarea.value = text; textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } static exportIDM(url, referrer) { return url.map(e => `<\r\n${e}\r\nreferer: ${referrer}\r\n>\r\n`).join(''); } static allowDrag(e) { e.stopPropagation(); e.preventDefault(); } static beforeUnloadHandler(e) { return e.returnValue = '脚本还没做完工作,真的要退出吗?'; } static hintInfo(text, playerWin) { let infoDiv = playerWin.document.createElement('div'); infoDiv.className = 'bilibili-player-video-toast-bottom'; infoDiv.innerHTML = `
${text}
`; playerWin.document.getElementsByClassName('bilibili-player-video-toast-wrp')[0].appendChild(infoDiv); setTimeout(() => infoDiv.remove(), 3000); } static getOption(playerWin) { let rawOption = null; try { rawOption = JSON.parse(playerWin.localStorage.getItem('BiliTwin')); } catch (e) { } finally { if (!rawOption) rawOption = {}; rawOption.setStorage = (n, i) => playerWin.localStorage.setItem(n, i); rawOption.getStorage = n => playerWin.localStorage.getItem(n); const defaultOption = { autoDefault: true, autoFLV: false, autoMP4: false, cache: true, partial: true, proxy: true, blocker: true, font: true, badgeWatchLater: true, dblclick: true, scroll: true, recommend: true, electric: true, electricSkippable: false, lift: true, autoResume: true, autoPlay: false, autoWideScreen: false, autoFullScreen: false, oped: true, focus: true, menuFocus: true, limitedKeydown: true, speech: false, series: true, betabeta: false }; return Object.assign({}, defaultOption, rawOption, debugOption); } } static saveOption(option) { return option.setStorage('BiliTwin', JSON.stringify(option)); } static outdatedEngineClearance() { if (!Promise || !MutationObserver) { alert('这个浏览器实在太老了,脚本决定罢工。'); throw 'BiliTwin: browser outdated: Promise or MutationObserver unsupported'; } } static firefoxClearance() { if (navigator.userAgent.includes('Firefox')) { debugOption.proxy = false; if (!window.navigator.temporaryStorage && !window.navigator.mozTemporaryStorage) window.navigator.temporaryStorage = { queryUsageAndQuota: func => func(-1048576, 10484711424) }; } } static xpcWrapperClearance() { if (top.unsafeWindow) { Object.defineProperty(window, 'cid', { configurable: true, get: () => String(unsafeWindow.cid) }); Object.defineProperty(window, 'player', { configurable: true, get: () => ({ destroy: unsafeWindow.player.destroy, reloadAccess: unsafeWindow.player.reloadAccess }) }); Object.defineProperty(window, 'jQuery', { configurable: true, get: () => unsafeWindow.jQuery, }); Object.defineProperty(window, 'fetch', { configurable: true, get: () => unsafeWindow.fetch.bind(unsafeWindow), set: _fetch => unsafeWindow.fetch = _fetch.bind(unsafeWindow) }); } } static styleClearance() { let ret = ` .bilibili-player-context-menu-container.black ul.bilitwin li.context-menu-function > a:hover { background: rgba(255,255,255,.12); transition: all .3s ease-in-out; cursor: pointer; } `; if (top.getComputedStyle(top.document.body).color != 'rgb(34, 34, 34)') ret += ` .bilitwin a { cursor: pointer; color: #00a1d6; } .bilitwin a:hover { color: #f25d8e; } .bilitwin button { color: #fff; cursor: pointer; text-align: center; border-radius: 4px; background-color: #00a1d6; vertical-align: middle; border: 1px solid #00a1d6; transition: .1s; transition-property: background-color,border,color; user-select: none; } .bilitwin button:hover { background-color: #00b5e5; border-color: #00b5e5; } .bilitwin progress { -webkit-appearance: progress; } `; let style = document.createElement('style'); style.type = 'text/css'; style.rel = 'stylesheet'; style.textContent = ret; document.head.appendChild(style); } static cleanUp() { Array.from(document.getElementsByClassName('bilitwin')) .filter(e => e.textContent.includes('FLV分段')) .forEach(e => Array.from(e.getElementsByTagName('a')).forEach( e => e.textContent == '取消' && e.click() )); Array.from(document.getElementsByClassName('bilitwin')).forEach(e => e.remove()); } static async start() { let cidRefresh = new AsyncContainer(); let href = location.href; // 1. playerWin and option let playerWin; try { playerWin = await UI.getPlayerWin(); } catch (e) { if (e == 'Need H5 Player') UI.requestH5Player(); throw e; } let option = UI.getOption(playerWin); let optionDiv = UI.genOptionDiv(option); document.body.appendChild(optionDiv); // 2. monkey and polyfill let monkeyTitle; let displayPolyfillDataDiv = polyfill => UI.displayPolyfillDataDiv(polyfill); let [monkey, polyfill] = await Promise.all([ (async () => { let monkey = new BiliMonkey(playerWin, option); await monkey.execOptions(); monkeyTitle = UI.titleAppend(monkey); return monkey; })(), (async () => { let polyfill = new BiliPolyfill(playerWin, option, t => UI.hintInfo(t, playerWin)); await polyfill.setFunctions(); return polyfill; })() ]); if (href != location.href) return UI.cleanUp(); // 3. menu UI.menuAppend(playerWin, { monkey, monkeyTitle, polyfill, displayPolyfillDataDiv, optionDiv }); // 4. refresh let h = () => { let video = playerWin.document.getElementsByTagName('video')[0]; if (video) video.addEventListener('emptied', h); else setTimeout(() => cidRefresh.resolve(), 0); } playerWin.document.getElementsByTagName('video')[0].addEventListener('emptied', h); playerWin.addEventListener('unload', () => setTimeout(() => cidRefresh.resolve(), 0)); // 5. debug if (debugOption.debug && top.console) top.console.clear(); if (debugOption.debug) ([(top.unsafeWindow || top).m, (top.unsafeWindow || top).p] = [monkey, polyfill]); await cidRefresh; UI.cleanUp(); } static async init() { if (!document.body) return; UI.outdatedEngineClearance(); UI.firefoxClearance(); UI.styleClearance(); while (1) { await UI.start(); } } } UI.init();