您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
bilibili/哔哩哔哩:超清FLV下载,FLV合并,原生MP4下载,弹幕ASS下载,MKV打包,播放体验增强,原生appsecret,不借助其他网站
当前为
// ==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.11 // @author qli5 // @copyright qli5, 2014+, 田生, grepmusic, zheng qian, ryiwamoto // @license Mozilla Public License 2.0; http://www.mozilla.org/MPL/2.0/ // @grant none // ==/UserScript== let debugOption = { // console会清空,生成 window.m 和 window.p //debug: 1, // 别拖啦~ //betabeta: 1, // UP主不容易,B站也不容易,充电是有益的尝试,我不鼓励跳。 //electricSkippable: 0, }; /** * @author qli5 <goodlq11[at](163|gmail).com> * * 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 * <https://www.npmjs.com/package/simple-ebml-builder> * 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 resultParts = []; 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; }); for (let tag of flv.tags) { if (tag.tagType == 0x12 && !foundDuration) { duration += tag.getDuration(); foundDuration = 1; if (blob == blobs[0]) { resultParts.push(new Blob([flv.header, flv.firstPreviousTagSize])); ({ duration, durationDataView } = tag.getDurationAndView()); tag.stripKeyframesScriptData(); resultParts.push(new Blob([tag.tagHeader])); resultParts.push(tag.tagData); resultParts.push(new Blob([tag.previousSize])); } } else if (tag.tagType == 0x08 || tag.tagType == 0x09) { lasttimestamp[tag.tagType - 0x08] = bts + tag.getCombinedTimestamp(); tag.setCombinedTimestamp(lasttimestamp[tag.tagType - 0x08]); resultParts.push(new Blob([tag.tagHeader, tag.tagData, tag.previousSize])); } } } durationDataView.setFloat64(0, duration); return new Blob(resultParts); } } 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': [ // 字形(会自动选择最前面一个可用的) 'Microsoft YaHei UI', 'Microsoft YaHei', '文泉驿正黑', 'STHeitiSC', '黑体', ], '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); 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}},25,&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,1,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0\\nStyle: R2L,{{font}},25,&H{{alpha}}FFFFFF,&H{{alpha}}FFFFFF,&H{{alpha}}000000,&H{{alpha}}000000,1,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', config, info, {'alpha': hexAlpha(config.opacity) } ); // 补齐数字开头的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(` <p> 加载文件…… loading files... <progress value="0" max="100" id="fileProgress"></progress> </p> <p> 构建mkv…… building mkv... <progress value="0" max="100" id="mkvProgress"></progress> </p> <p> <a id="a" download="merged.mkv">merged.mkv</a> </p> <script> /** * FLV + ASS => MKV transmuxer * Demux FLV into H264 + AAC stream and ASS into line stream; then * remux them into a MKV file. * * @author qli5 <goodlq11[at](163|gmail).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/. * * The FLV demuxer is from flv.js <https://github.com/Bilibili/flv.js/> * by zheng qian <[email protected]>, licensed under Apache 2.0. * * The EMBL builder is from simple-ebml-builder * <https://www.npmjs.com/package/simple-ebml-builder> by ryiwamoto, * licensed under MIT. */ // nodejs polyfill if (typeof Blob == 'undefined') { var Blob = class { constructor(array) { return Buffer.concat(array.map(Buffer.from.bind(Buffer))); } }; } if (typeof TextEncoder == 'undefined') { var TextEncoder = class { /** * * @param {string} chunk * @returns {Uint8Array} */ encode(chunk) { return Buffer.from(chunk, 'utf-8'); } } } if (typeof TextDecoder == 'undefined') { const StringDecoder = require('string_decoder').StringDecoder; var TextDecoder = class extends StringDecoder { /** * * @param {ArrayBuffer} chunk * @returns {string} */ decode(chunk) { return this.end(Buffer.from(chunk)); } } } /** * The FLV demuxer is from flv.js * * Copyright (C) 2016 Bilibili. All Rights Reserved. * * @author zheng qian <[email protected]> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const FLVDemuxer = (() => { // I browserified flv.js manually - so that I can know how it works if (typeof navigator == 'undefined') navigator = { userAgent: 'chrome', } // import FLVDemuxer from 'flv.js/src/demux/flv-demuxer'; // ..import Log from '../utils/logger.js'; const Log = { e: console.error.bind(console), w: console.warn.bind(console), i: console.log.bind(console), v: console.log.bind(console), }; // ..import AMF from './amf-parser.js'; // ....import Log from '../utils/logger.js'; // ....import decodeUTF8 from '../utils/utf8-conv.js'; function checkContinuation(uint8array, start, checkLength) { let array = uint8array; if (start + checkLength < array.length) { while (checkLength--) { if ((array[++start] & 0xC0) !== 0x80) return false; } return true; } else { return false; } } function decodeUTF8(uint8array) { let out = []; let input = uint8array; let i = 0; let length = uint8array.length; while (i < length) { if (input[i] < 0x80) { out.push(String.fromCharCode(input[i])); ++i; continue; } else if (input[i] < 0xC0) { // fallthrough } else if (input[i] < 0xE0) { if (checkContinuation(input, i, 1)) { let ucs4 = (input[i] & 0x1F) << 6 | (input[i + 1] & 0x3F); if (ucs4 >= 0x80) { out.push(String.fromCharCode(ucs4 & 0xFFFF)); i += 2; continue; } } } else if (input[i] < 0xF0) { if (checkContinuation(input, i, 2)) { let ucs4 = (input[i] & 0xF) << 12 | (input[i + 1] & 0x3F) << 6 | input[i + 2] & 0x3F; if (ucs4 >= 0x800 && (ucs4 & 0xF800) !== 0xD800) { out.push(String.fromCharCode(ucs4 & 0xFFFF)); i += 3; continue; } } } else if (input[i] < 0xF8) { if (checkContinuation(input, i, 3)) { let ucs4 = (input[i] & 0x7) << 18 | (input[i + 1] & 0x3F) << 12 | (input[i + 2] & 0x3F) << 6 | (input[i + 3] & 0x3F); if (ucs4 > 0x10000 && ucs4 < 0x110000) { ucs4 -= 0x10000; out.push(String.fromCharCode((ucs4 >>> 10) | 0xD800)); out.push(String.fromCharCode((ucs4 & 0x3FF) | 0xDC00)); i += 4; continue; } } } out.push(String.fromCharCode(0xFFFD)); ++i; } return out.join(''); } // ....import {IllegalStateException} from '../utils/exception.js'; class IllegalStateException extends Error { } let le = (function () { let buf = new ArrayBuffer(2); (new DataView(buf)).setInt16(0, 256, true); // little-endian write return (new Int16Array(buf))[0] === 256; // platform-spec read, if equal then LE })(); class AMF { static parseScriptData(arrayBuffer, dataOffset, dataSize) { let data = {}; try { let name = AMF.parseValue(arrayBuffer, dataOffset, dataSize); let value = AMF.parseValue(arrayBuffer, dataOffset + name.size, dataSize - name.size); data[name.data] = value.data; } catch (e) { Log.e('AMF', e.toString()); } return data; } static parseObject(arrayBuffer, dataOffset, dataSize) { if (dataSize < 3) { throw new IllegalStateException('Data not enough when parse ScriptDataObject'); } let name = AMF.parseString(arrayBuffer, dataOffset, dataSize); let value = AMF.parseValue(arrayBuffer, dataOffset + name.size, dataSize - name.size); let isObjectEnd = value.objectEnd; return { data: { name: name.data, value: value.data }, size: name.size + value.size, objectEnd: isObjectEnd }; } static parseVariable(arrayBuffer, dataOffset, dataSize) { return AMF.parseObject(arrayBuffer, dataOffset, dataSize); } static parseString(arrayBuffer, dataOffset, dataSize) { if (dataSize < 2) { throw new IllegalStateException('Data not enough when parse String'); } let v = new DataView(arrayBuffer, dataOffset, dataSize); let length = v.getUint16(0, !le); let str; if (length > 0) { str = decodeUTF8(new Uint8Array(arrayBuffer, dataOffset + 2, length)); } else { str = ''; } return { data: str, size: 2 + length }; } static parseLongString(arrayBuffer, dataOffset, dataSize) { if (dataSize < 4) { throw new IllegalStateException('Data not enough when parse LongString'); } let v = new DataView(arrayBuffer, dataOffset, dataSize); let length = v.getUint32(0, !le); let str; if (length > 0) { str = decodeUTF8(new Uint8Array(arrayBuffer, dataOffset + 4, length)); } else { str = ''; } return { data: str, size: 4 + length }; } static parseDate(arrayBuffer, dataOffset, dataSize) { if (dataSize < 10) { throw new IllegalStateException('Data size invalid when parse Date'); } let v = new DataView(arrayBuffer, dataOffset, dataSize); let timestamp = v.getFloat64(0, !le); let localTimeOffset = v.getInt16(8, !le); timestamp += localTimeOffset * 60 * 1000; // get UTC time return { data: new Date(timestamp), size: 8 + 2 }; } static parseValue(arrayBuffer, dataOffset, dataSize) { if (dataSize < 1) { throw new IllegalStateException('Data not enough when parse Value'); } let v = new DataView(arrayBuffer, dataOffset, dataSize); let offset = 1; let type = v.getUint8(0); let value; let objectEnd = false; try { switch (type) { case 0: // Number(Double) type value = v.getFloat64(1, !le); offset += 8; break; case 1: { // Boolean type let b = v.getUint8(1); value = b ? true : false; offset += 1; break; } case 2: { // String type let amfstr = AMF.parseString(arrayBuffer, dataOffset + 1, dataSize - 1); value = amfstr.data; offset += amfstr.size; break; } case 3: { // Object(s) type value = {}; let terminal = 0; // workaround for malformed Objects which has missing ScriptDataObjectEnd if ((v.getUint32(dataSize - 4, !le) & 0x00FFFFFF) === 9) { terminal = 3; } while (offset < dataSize - 4) { // 4 === type(UI8) + ScriptDataObjectEnd(UI24) let amfobj = AMF.parseObject(arrayBuffer, dataOffset + offset, dataSize - offset - terminal); if (amfobj.objectEnd) break; value[amfobj.data.name] = amfobj.data.value; offset += amfobj.size; } if (offset <= dataSize - 3) { let marker = v.getUint32(offset - 1, !le) & 0x00FFFFFF; if (marker === 9) { offset += 3; } } break; } case 8: { // ECMA array type (Mixed array) value = {}; offset += 4; // ECMAArrayLength(UI32) let terminal = 0; // workaround for malformed MixedArrays which has missing ScriptDataObjectEnd if ((v.getUint32(dataSize - 4, !le) & 0x00FFFFFF) === 9) { terminal = 3; } while (offset < dataSize - 8) { // 8 === type(UI8) + ECMAArrayLength(UI32) + ScriptDataVariableEnd(UI24) let amfvar = AMF.parseVariable(arrayBuffer, dataOffset + offset, dataSize - offset - terminal); if (amfvar.objectEnd) break; value[amfvar.data.name] = amfvar.data.value; offset += amfvar.size; } if (offset <= dataSize - 3) { let marker = v.getUint32(offset - 1, !le) & 0x00FFFFFF; if (marker === 9) { offset += 3; } } break; } case 9: // ScriptDataObjectEnd value = undefined; offset = 1; objectEnd = true; break; case 10: { // Strict array type // ScriptDataValue[n]. NOTE: according to video_file_format_spec_v10_1.pdf value = []; let strictArrayLength = v.getUint32(1, !le); offset += 4; for (let i = 0; i < strictArrayLength; i++) { let val = AMF.parseValue(arrayBuffer, dataOffset + offset, dataSize - offset); value.push(val.data); offset += val.size; } break; } case 11: { // Date type let date = AMF.parseDate(arrayBuffer, dataOffset + 1, dataSize - 1); value = date.data; offset += date.size; break; } case 12: { // Long string type let amfLongStr = AMF.parseString(arrayBuffer, dataOffset + 1, dataSize - 1); value = amfLongStr.data; offset += amfLongStr.size; break; } default: // ignore and skip offset = dataSize; Log.w('AMF', 'Unsupported AMF value type ' + type); } } catch (e) { Log.e('AMF', e.toString()); } return { data: value, size: offset, objectEnd: objectEnd }; } } // ..import SPSParser from './sps-parser.js'; // ....import ExpGolomb from './exp-golomb.js'; // ......import {IllegalStateException, InvalidArgumentException} from '../utils/exception.js'; class InvalidArgumentException extends Error { } class ExpGolomb { constructor(uint8array) { this.TAG = 'ExpGolomb'; this._buffer = uint8array; this._buffer_index = 0; this._total_bytes = uint8array.byteLength; this._total_bits = uint8array.byteLength * 8; this._current_word = 0; this._current_word_bits_left = 0; } destroy() { this._buffer = null; } _fillCurrentWord() { let buffer_bytes_left = this._total_bytes - this._buffer_index; if (buffer_bytes_left <= 0) throw new IllegalStateException('ExpGolomb: _fillCurrentWord() but no bytes available'); let bytes_read = Math.min(4, buffer_bytes_left); let word = new Uint8Array(4); word.set(this._buffer.subarray(this._buffer_index, this._buffer_index + bytes_read)); this._current_word = new DataView(word.buffer).getUint32(0, false); this._buffer_index += bytes_read; this._current_word_bits_left = bytes_read * 8; } readBits(bits) { if (bits > 32) throw new InvalidArgumentException('ExpGolomb: readBits() bits exceeded max 32bits!'); if (bits <= this._current_word_bits_left) { let result = this._current_word >>> (32 - bits); this._current_word <<= bits; this._current_word_bits_left -= bits; return result; } let result = this._current_word_bits_left ? this._current_word : 0; result = result >>> (32 - this._current_word_bits_left); let bits_need_left = bits - this._current_word_bits_left; this._fillCurrentWord(); let bits_read_next = Math.min(bits_need_left, this._current_word_bits_left); let result2 = this._current_word >>> (32 - bits_read_next); this._current_word <<= bits_read_next; this._current_word_bits_left -= bits_read_next; result = (result << bits_read_next) | result2; return result; } readBool() { return this.readBits(1) === 1; } readByte() { return this.readBits(8); } _skipLeadingZero() { let zero_count; for (zero_count = 0; zero_count < this._current_word_bits_left; zero_count++) { if (0 !== (this._current_word & (0x80000000 >>> zero_count))) { this._current_word <<= zero_count; this._current_word_bits_left -= zero_count; return zero_count; } } this._fillCurrentWord(); return zero_count + this._skipLeadingZero(); } readUEG() { // unsigned exponential golomb let leading_zeros = this._skipLeadingZero(); return this.readBits(leading_zeros + 1) - 1; } readSEG() { // signed exponential golomb let value = this.readUEG(); if (value & 0x01) { return (value + 1) >>> 1; } else { return -1 * (value >>> 1); } } } class SPSParser { static _ebsp2rbsp(uint8array) { let src = uint8array; let src_length = src.byteLength; let dst = new Uint8Array(src_length); let dst_idx = 0; for (let i = 0; i < src_length; i++) { if (i >= 2) { // Unescape: Skip 0x03 after 00 00 if (src[i] === 0x03 && src[i - 1] === 0x00 && src[i - 2] === 0x00) { continue; } } dst[dst_idx] = src[i]; dst_idx++; } return new Uint8Array(dst.buffer, 0, dst_idx); } static parseSPS(uint8array) { let rbsp = SPSParser._ebsp2rbsp(uint8array); let gb = new ExpGolomb(rbsp); gb.readByte(); let profile_idc = gb.readByte(); // profile_idc gb.readByte(); // constraint_set_flags[5] + reserved_zero[3] let level_idc = gb.readByte(); // level_idc gb.readUEG(); // seq_parameter_set_id let profile_string = SPSParser.getProfileString(profile_idc); let level_string = SPSParser.getLevelString(level_idc); let chroma_format_idc = 1; let chroma_format = 420; let chroma_format_table = [0, 420, 422, 444]; let bit_depth = 8; if (profile_idc === 100 || profile_idc === 110 || profile_idc === 122 || profile_idc === 244 || profile_idc === 44 || profile_idc === 83 || profile_idc === 86 || profile_idc === 118 || profile_idc === 128 || profile_idc === 138 || profile_idc === 144) { chroma_format_idc = gb.readUEG(); if (chroma_format_idc === 3) { gb.readBits(1); // separate_colour_plane_flag } if (chroma_format_idc <= 3) { chroma_format = chroma_format_table[chroma_format_idc]; } bit_depth = gb.readUEG() + 8; // bit_depth_luma_minus8 gb.readUEG(); // bit_depth_chroma_minus8 gb.readBits(1); // qpprime_y_zero_transform_bypass_flag if (gb.readBool()) { // seq_scaling_matrix_present_flag let scaling_list_count = (chroma_format_idc !== 3) ? 8 : 12; for (let i = 0; i < scaling_list_count; i++) { if (gb.readBool()) { // seq_scaling_list_present_flag if (i < 6) { SPSParser._skipScalingList(gb, 16); } else { SPSParser._skipScalingList(gb, 64); } } } } } gb.readUEG(); // log2_max_frame_num_minus4 let pic_order_cnt_type = gb.readUEG(); if (pic_order_cnt_type === 0) { gb.readUEG(); // log2_max_pic_order_cnt_lsb_minus_4 } else if (pic_order_cnt_type === 1) { gb.readBits(1); // delta_pic_order_always_zero_flag gb.readSEG(); // offset_for_non_ref_pic gb.readSEG(); // offset_for_top_to_bottom_field let num_ref_frames_in_pic_order_cnt_cycle = gb.readUEG(); for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) { gb.readSEG(); // offset_for_ref_frame } } gb.readUEG(); // max_num_ref_frames gb.readBits(1); // gaps_in_frame_num_value_allowed_flag let pic_width_in_mbs_minus1 = gb.readUEG(); let pic_height_in_map_units_minus1 = gb.readUEG(); let frame_mbs_only_flag = gb.readBits(1); if (frame_mbs_only_flag === 0) { gb.readBits(1); // mb_adaptive_frame_field_flag } gb.readBits(1); // direct_8x8_inference_flag let frame_crop_left_offset = 0; let frame_crop_right_offset = 0; let frame_crop_top_offset = 0; let frame_crop_bottom_offset = 0; let frame_cropping_flag = gb.readBool(); if (frame_cropping_flag) { frame_crop_left_offset = gb.readUEG(); frame_crop_right_offset = gb.readUEG(); frame_crop_top_offset = gb.readUEG(); frame_crop_bottom_offset = gb.readUEG(); } let sar_width = 1, sar_height = 1; let fps = 0, fps_fixed = true, fps_num = 0, fps_den = 0; let vui_parameters_present_flag = gb.readBool(); if (vui_parameters_present_flag) { if (gb.readBool()) { // aspect_ratio_info_present_flag let aspect_ratio_idc = gb.readByte(); let sar_w_table = [1, 12, 10, 16, 40, 24, 20, 32, 80, 18, 15, 64, 160, 4, 3, 2]; let sar_h_table = [1, 11, 11, 11, 33, 11, 11, 11, 33, 11, 11, 33, 99, 3, 2, 1]; if (aspect_ratio_idc > 0 && aspect_ratio_idc < 16) { sar_width = sar_w_table[aspect_ratio_idc - 1]; sar_height = sar_h_table[aspect_ratio_idc - 1]; } else if (aspect_ratio_idc === 255) { sar_width = gb.readByte() << 8 | gb.readByte(); sar_height = gb.readByte() << 8 | gb.readByte(); } } if (gb.readBool()) { // overscan_info_present_flag gb.readBool(); // overscan_appropriate_flag } if (gb.readBool()) { // video_signal_type_present_flag gb.readBits(4); // video_format & video_full_range_flag if (gb.readBool()) { // colour_description_present_flag gb.readBits(24); // colour_primaries & transfer_characteristics & matrix_coefficients } } if (gb.readBool()) { // chroma_loc_info_present_flag gb.readUEG(); // chroma_sample_loc_type_top_field gb.readUEG(); // chroma_sample_loc_type_bottom_field } if (gb.readBool()) { // timing_info_present_flag let num_units_in_tick = gb.readBits(32); let time_scale = gb.readBits(32); fps_fixed = gb.readBool(); // fixed_frame_rate_flag fps_num = time_scale; fps_den = num_units_in_tick * 2; fps = fps_num / fps_den; } } let sarScale = 1; if (sar_width !== 1 || sar_height !== 1) { sarScale = sar_width / sar_height; } let crop_unit_x = 0, crop_unit_y = 0; if (chroma_format_idc === 0) { crop_unit_x = 1; crop_unit_y = 2 - frame_mbs_only_flag; } else { let sub_wc = (chroma_format_idc === 3) ? 1 : 2; let sub_hc = (chroma_format_idc === 1) ? 2 : 1; crop_unit_x = sub_wc; crop_unit_y = sub_hc * (2 - frame_mbs_only_flag); } let codec_width = (pic_width_in_mbs_minus1 + 1) * 16; let codec_height = (2 - frame_mbs_only_flag) * ((pic_height_in_map_units_minus1 + 1) * 16); codec_width -= (frame_crop_left_offset + frame_crop_right_offset) * crop_unit_x; codec_height -= (frame_crop_top_offset + frame_crop_bottom_offset) * crop_unit_y; let present_width = Math.ceil(codec_width * sarScale); gb.destroy(); gb = null; return { profile_string: profile_string, // baseline, high, high10, ... level_string: level_string, // 3, 3.1, 4, 4.1, 5, 5.1, ... bit_depth: bit_depth, // 8bit, 10bit, ... chroma_format: chroma_format, // 4:2:0, 4:2:2, ... chroma_format_string: SPSParser.getChromaFormatString(chroma_format), frame_rate: { fixed: fps_fixed, fps: fps, fps_den: fps_den, fps_num: fps_num }, sar_ratio: { width: sar_width, height: sar_height }, codec_size: { width: codec_width, height: codec_height }, present_size: { width: present_width, height: codec_height } }; } static _skipScalingList(gb, count) { let last_scale = 8, next_scale = 8; let delta_scale = 0; for (let i = 0; i < count; i++) { if (next_scale !== 0) { delta_scale = gb.readSEG(); next_scale = (last_scale + delta_scale + 256) % 256; } last_scale = (next_scale === 0) ? last_scale : next_scale; } } static getProfileString(profile_idc) { switch (profile_idc) { case 66: return 'Baseline'; case 77: return 'Main'; case 88: return 'Extended'; case 100: return 'High'; case 110: return 'High10'; case 122: return 'High422'; case 244: return 'High444'; default: return 'Unknown'; } } static getLevelString(level_idc) { return (level_idc / 10).toFixed(1); } static getChromaFormatString(chroma) { switch (chroma) { case 420: return '4:2:0'; case 422: return '4:2:2'; case 444: return '4:4:4'; default: return 'Unknown'; } } } // ..import DemuxErrors from './demux-errors.js'; const DemuxErrors = { OK: 'OK', FORMAT_ERROR: 'FormatError', FORMAT_UNSUPPORTED: 'FormatUnsupported', CODEC_UNSUPPORTED: 'CodecUnsupported' }; // ..import MediaInfo from '../core/media-info.js'; class MediaInfo { constructor() { this.mimeType = null; this.duration = null; this.hasAudio = null; this.hasVideo = null; this.audioCodec = null; this.videoCodec = null; this.audioDataRate = null; this.videoDataRate = null; this.audioSampleRate = null; this.audioChannelCount = null; this.width = null; this.height = null; this.fps = null; this.profile = null; this.level = null; this.chromaFormat = null; this.sarNum = null; this.sarDen = null; this.metadata = null; this.segments = null; // MediaInfo[] this.segmentCount = null; this.hasKeyframesIndex = null; this.keyframesIndex = null; } isComplete() { let audioInfoComplete = (this.hasAudio === false) || (this.hasAudio === true && this.audioCodec != null && this.audioSampleRate != null && this.audioChannelCount != null); let videoInfoComplete = (this.hasVideo === false) || (this.hasVideo === true && this.videoCodec != null && this.width != null && this.height != null && this.fps != null && this.profile != null && this.level != null && this.chromaFormat != null && this.sarNum != null && this.sarDen != null); // keyframesIndex may not be present return this.mimeType != null && this.duration != null && this.metadata != null && this.hasKeyframesIndex != null && audioInfoComplete && videoInfoComplete; } isSeekable() { return this.hasKeyframesIndex === true; } getNearestKeyframe(milliseconds) { if (this.keyframesIndex == null) { return null; } let table = this.keyframesIndex; let keyframeIdx = this._search(table.times, milliseconds); return { index: keyframeIdx, milliseconds: table.times[keyframeIdx], fileposition: table.filepositions[keyframeIdx] }; } _search(list, value) { let idx = 0; let last = list.length - 1; let mid = 0; let lbound = 0; let ubound = last; if (value < list[0]) { idx = 0; lbound = ubound + 1; // skip search } while (lbound <= ubound) { mid = lbound + Math.floor((ubound - lbound) / 2); if (mid === last || (value >= list[mid] && value < list[mid + 1])) { idx = mid; break; } else if (list[mid] < value) { lbound = mid + 1; } else { ubound = mid - 1; } } return idx; } } function Swap16(src) { return (((src >>> 8) & 0xFF) | ((src & 0xFF) << 8)); } function Swap32(src) { return (((src & 0xFF000000) >>> 24) | ((src & 0x00FF0000) >>> 8) | ((src & 0x0000FF00) << 8) | ((src & 0x000000FF) << 24)); } function ReadBig32(array, index) { return ((array[index] << 24) | (array[index + 1] << 16) | (array[index + 2] << 8) | (array[index + 3])); } class FLVDemuxer { /** * Create a new FLV demuxer * @param {Object} probeData * @param {boolean} probeData.match * @param {number} probeData.consumed * @param {number} probeData.dataOffset * @param {booleam} probeData.hasAudioTrack * @param {boolean} probeData.hasVideoTrack * @param {*} config */ constructor(probeData, config) { this.TAG = 'FLVDemuxer'; this._config = config; this._onError = null; this._onMediaInfo = null; this._onTrackMetadata = null; this._onDataAvailable = null; this._dataOffset = probeData.dataOffset; this._firstParse = true; this._dispatch = false; this._hasAudio = probeData.hasAudioTrack; this._hasVideo = probeData.hasVideoTrack; this._hasAudioFlagOverrided = false; this._hasVideoFlagOverrided = false; this._audioInitialMetadataDispatched = false; this._videoInitialMetadataDispatched = false; this._mediaInfo = new MediaInfo(); this._mediaInfo.hasAudio = this._hasAudio; this._mediaInfo.hasVideo = this._hasVideo; this._metadata = null; this._audioMetadata = null; this._videoMetadata = null; this._naluLengthSize = 4; this._timestampBase = 0; // int32, in milliseconds this._timescale = 1000; this._duration = 0; // int32, in milliseconds this._durationOverrided = false; this._referenceFrameRate = { fixed: true, fps: 23.976, fps_num: 23976, fps_den: 1000 }; this._flvSoundRateTable = [5500, 11025, 22050, 44100, 48000]; this._mpegSamplingRates = [ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 ]; this._mpegAudioV10SampleRateTable = [44100, 48000, 32000, 0]; this._mpegAudioV20SampleRateTable = [22050, 24000, 16000, 0]; this._mpegAudioV25SampleRateTable = [11025, 12000, 8000, 0]; this._mpegAudioL1BitRateTable = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, -1]; this._mpegAudioL2BitRateTable = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1]; this._mpegAudioL3BitRateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, -1]; this._videoTrack = { type: 'video', id: 1, sequenceNumber: 0, samples: [], length: 0 }; this._audioTrack = { type: 'audio', id: 2, sequenceNumber: 0, samples: [], length: 0 }; this._littleEndian = (function () { let buf = new ArrayBuffer(2); (new DataView(buf)).setInt16(0, 256, true); // little-endian write return (new Int16Array(buf))[0] === 256; // platform-spec read, if equal then LE })(); } destroy() { this._mediaInfo = null; this._metadata = null; this._audioMetadata = null; this._videoMetadata = null; this._videoTrack = null; this._audioTrack = null; this._onError = null; this._onMediaInfo = null; this._onTrackMetadata = null; this._onDataAvailable = null; } /** * Probe the flv data * @param {ArrayBuffer} buffer * @returns {Object} - probeData to be feed into constructor */ static probe(buffer) { let data = new Uint8Array(buffer); let mismatch = { match: false }; if (data[0] !== 0x46 || data[1] !== 0x4C || data[2] !== 0x56 || data[3] !== 0x01) { return mismatch; } let hasAudio = ((data[4] & 4) >>> 2) !== 0; let hasVideo = (data[4] & 1) !== 0; let offset = ReadBig32(data, 5); if (offset < 9) { return mismatch; } return { match: true, consumed: offset, dataOffset: offset, hasAudioTrack: hasAudio, hasVideoTrack: hasVideo }; } bindDataSource(loader) { loader.onDataArrival = this.parseChunks.bind(this); return this; } // prototype: function(type: string, metadata: any): void get onTrackMetadata() { return this._onTrackMetadata; } set onTrackMetadata(callback) { this._onTrackMetadata = callback; } // prototype: function(mediaInfo: MediaInfo): void get onMediaInfo() { return this._onMediaInfo; } set onMediaInfo(callback) { this._onMediaInfo = callback; } // prototype: function(type: number, info: string): void get onError() { return this._onError; } set onError(callback) { this._onError = callback; } // prototype: function(videoTrack: any, audioTrack: any): void get onDataAvailable() { return this._onDataAvailable; } set onDataAvailable(callback) { this._onDataAvailable = callback; } // timestamp base for output samples, must be in milliseconds get timestampBase() { return this._timestampBase; } set timestampBase(base) { this._timestampBase = base; } get overridedDuration() { return this._duration; } // Force-override media duration. Must be in milliseconds, int32 set overridedDuration(duration) { this._durationOverrided = true; this._duration = duration; this._mediaInfo.duration = duration; } // Force-override audio track present flag, boolean set overridedHasAudio(hasAudio) { this._hasAudioFlagOverrided = true; this._hasAudio = hasAudio; this._mediaInfo.hasAudio = hasAudio; } // Force-override video track present flag, boolean set overridedHasVideo(hasVideo) { this._hasVideoFlagOverrided = true; this._hasVideo = hasVideo; this._mediaInfo.hasVideo = hasVideo; } resetMediaInfo() { this._mediaInfo = new MediaInfo(); } _isInitialMetadataDispatched() { if (this._hasAudio && this._hasVideo) { // both audio & video return this._audioInitialMetadataDispatched && this._videoInitialMetadataDispatched; } if (this._hasAudio && !this._hasVideo) { // audio only return this._audioInitialMetadataDispatched; } if (!this._hasAudio && this._hasVideo) { // video only return this._videoInitialMetadataDispatched; } return false; } // function parseChunks(chunk: ArrayBuffer, byteStart: number): number; parseChunks(chunk, byteStart) { if (!this._onError || !this._onMediaInfo || !this._onTrackMetadata || !this._onDataAvailable) { throw new IllegalStateException('Flv: onError & onMediaInfo & onTrackMetadata & onDataAvailable callback must be specified'); } // qli5: fix nonzero byteStart let offset = byteStart || 0; let le = this._littleEndian; if (byteStart === 0) { // buffer with FLV header if (chunk.byteLength > 13) { let probeData = FLVDemuxer.probe(chunk); offset = probeData.dataOffset; } else { return 0; } } if (this._firstParse) { // handle PreviousTagSize0 before Tag1 this._firstParse = false; if (offset !== this._dataOffset) { Log.w(this.TAG, 'First time parsing but chunk byteStart invalid!'); } let v = new DataView(chunk, offset); let prevTagSize0 = v.getUint32(0, !le); if (prevTagSize0 !== 0) { Log.w(this.TAG, 'PrevTagSize0 !== 0 !!!'); } offset += 4; } while (offset < chunk.byteLength) { this._dispatch = true; let v = new DataView(chunk, offset); if (offset + 11 + 4 > chunk.byteLength) { // data not enough for parsing an flv tag break; } let tagType = v.getUint8(0); let dataSize = v.getUint32(0, !le) & 0x00FFFFFF; if (offset + 11 + dataSize + 4 > chunk.byteLength) { // data not enough for parsing actual data body break; } if (tagType !== 8 && tagType !== 9 && tagType !== 18) { Log.w(this.TAG, \`Unsupported tag type \${tagType}, skipped\`); // consume the whole tag (skip it) offset += 11 + dataSize + 4; continue; } let ts2 = v.getUint8(4); let ts1 = v.getUint8(5); let ts0 = v.getUint8(6); let ts3 = v.getUint8(7); let timestamp = ts0 | (ts1 << 8) | (ts2 << 16) | (ts3 << 24); let streamId = v.getUint32(7, !le) & 0x00FFFFFF; if (streamId !== 0) { Log.w(this.TAG, 'Meet tag which has StreamID != 0!'); } let dataOffset = offset + 11; switch (tagType) { case 8: // Audio this._parseAudioData(chunk, dataOffset, dataSize, timestamp); break; case 9: // Video this._parseVideoData(chunk, dataOffset, dataSize, timestamp, byteStart + offset); break; case 18: // ScriptDataObject this._parseScriptData(chunk, dataOffset, dataSize); break; } let prevTagSize = v.getUint32(11 + dataSize, !le); if (prevTagSize !== 11 + dataSize) { Log.w(this.TAG, \`Invalid PrevTagSize \${prevTagSize}\`); } offset += 11 + dataSize + 4; // tagBody + dataSize + prevTagSize } // dispatch parsed frames to consumer (typically, the remuxer) if (this._isInitialMetadataDispatched()) { if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { this._onDataAvailable(this._audioTrack, this._videoTrack); } } return offset; // consumed bytes, just equals latest offset index } _parseScriptData(arrayBuffer, dataOffset, dataSize) { let scriptData = AMF.parseScriptData(arrayBuffer, dataOffset, dataSize); if (scriptData.hasOwnProperty('onMetaData')) { if (scriptData.onMetaData == null || typeof scriptData.onMetaData !== 'object') { Log.w(this.TAG, 'Invalid onMetaData structure!'); return; } if (this._metadata) { Log.w(this.TAG, 'Found another onMetaData tag!'); } this._metadata = scriptData; let onMetaData = this._metadata.onMetaData; if (typeof onMetaData.hasAudio === 'boolean') { // hasAudio if (this._hasAudioFlagOverrided === false) { this._hasAudio = onMetaData.hasAudio; this._mediaInfo.hasAudio = this._hasAudio; } } if (typeof onMetaData.hasVideo === 'boolean') { // hasVideo if (this._hasVideoFlagOverrided === false) { this._hasVideo = onMetaData.hasVideo; this._mediaInfo.hasVideo = this._hasVideo; } } if (typeof onMetaData.audiodatarate === 'number') { // audiodatarate this._mediaInfo.audioDataRate = onMetaData.audiodatarate; } if (typeof onMetaData.videodatarate === 'number') { // videodatarate this._mediaInfo.videoDataRate = onMetaData.videodatarate; } if (typeof onMetaData.width === 'number') { // width this._mediaInfo.width = onMetaData.width; } if (typeof onMetaData.height === 'number') { // height this._mediaInfo.height = onMetaData.height; } if (typeof onMetaData.duration === 'number') { // duration if (!this._durationOverrided) { let duration = Math.floor(onMetaData.duration * this._timescale); this._duration = duration; this._mediaInfo.duration = duration; } } else { this._mediaInfo.duration = 0; } if (typeof onMetaData.framerate === 'number') { // framerate let fps_num = Math.floor(onMetaData.framerate * 1000); if (fps_num > 0) { let fps = fps_num / 1000; this._referenceFrameRate.fixed = true; this._referenceFrameRate.fps = fps; this._referenceFrameRate.fps_num = fps_num; this._referenceFrameRate.fps_den = 1000; this._mediaInfo.fps = fps; } } if (typeof onMetaData.keyframes === 'object') { // keyframes this._mediaInfo.hasKeyframesIndex = true; let keyframes = onMetaData.keyframes; this._mediaInfo.keyframesIndex = this._parseKeyframesIndex(keyframes); onMetaData.keyframes = null; // keyframes has been extracted, remove it } else { this._mediaInfo.hasKeyframesIndex = false; } this._dispatch = false; this._mediaInfo.metadata = onMetaData; Log.v(this.TAG, 'Parsed onMetaData'); if (this._mediaInfo.isComplete()) { this._onMediaInfo(this._mediaInfo); } } } _parseKeyframesIndex(keyframes) { let times = []; let filepositions = []; // ignore first keyframe which is actually AVC Sequence Header (AVCDecoderConfigurationRecord) for (let i = 1; i < keyframes.times.length; i++) { let time = this._timestampBase + Math.floor(keyframes.times[i] * 1000); times.push(time); filepositions.push(keyframes.filepositions[i]); } return { times: times, filepositions: filepositions }; } _parseAudioData(arrayBuffer, dataOffset, dataSize, tagTimestamp) { if (dataSize <= 1) { Log.w(this.TAG, 'Flv: Invalid audio packet, missing SoundData payload!'); return; } if (this._hasAudioFlagOverrided === true && this._hasAudio === false) { // If hasAudio: false indicated explicitly in MediaDataSource, // Ignore all the audio packets return; } let le = this._littleEndian; let v = new DataView(arrayBuffer, dataOffset, dataSize); let soundSpec = v.getUint8(0); let soundFormat = soundSpec >>> 4; if (soundFormat !== 2 && soundFormat !== 10) { // MP3 or AAC this._onError(DemuxErrors.CODEC_UNSUPPORTED, 'Flv: Unsupported audio codec idx: ' + soundFormat); return; } let soundRate = 0; let soundRateIndex = (soundSpec & 12) >>> 2; if (soundRateIndex >= 0 && soundRateIndex <= 4) { soundRate = this._flvSoundRateTable[soundRateIndex]; } else { this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid audio sample rate idx: ' + soundRateIndex); return; } let soundSize = (soundSpec & 2) >>> 1; // unused let soundType = (soundSpec & 1); let meta = this._audioMetadata; let track = this._audioTrack; if (!meta) { if (this._hasAudio === false && this._hasAudioFlagOverrided === false) { this._hasAudio = true; this._mediaInfo.hasAudio = true; } // initial metadata meta = this._audioMetadata = {}; meta.type = 'audio'; meta.id = track.id; meta.timescale = this._timescale; meta.duration = this._duration; meta.audioSampleRate = soundRate; meta.channelCount = (soundType === 0 ? 1 : 2); } if (soundFormat === 10) { // AAC let aacData = this._parseAACAudioData(arrayBuffer, dataOffset + 1, dataSize - 1); if (aacData == undefined) { return; } if (aacData.packetType === 0) { // AAC sequence header (AudioSpecificConfig) if (meta.config) { Log.w(this.TAG, 'Found another AudioSpecificConfig!'); } let misc = aacData.data; meta.audioSampleRate = misc.samplingRate; meta.channelCount = misc.channelCount; meta.codec = misc.codec; meta.originalCodec = misc.originalCodec; meta.config = misc.config; // added by qli5 meta.configRaw = misc.configRaw; // The decode result of an aac sample is 1024 PCM samples meta.refSampleDuration = 1024 / meta.audioSampleRate * meta.timescale; Log.v(this.TAG, 'Parsed AudioSpecificConfig'); if (this._isInitialMetadataDispatched()) { // Non-initial metadata, force dispatch (or flush) parsed frames to remuxer if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { this._onDataAvailable(this._audioTrack, this._videoTrack); } } else { this._audioInitialMetadataDispatched = true; } // then notify new metadata this._dispatch = false; this._onTrackMetadata('audio', meta); let mi = this._mediaInfo; mi.audioCodec = meta.originalCodec; mi.audioSampleRate = meta.audioSampleRate; mi.audioChannelCount = meta.channelCount; if (mi.hasVideo) { if (mi.videoCodec != null) { mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; } } else { mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"'; } if (mi.isComplete()) { this._onMediaInfo(mi); } } else if (aacData.packetType === 1) { // AAC raw frame data let dts = this._timestampBase + tagTimestamp; let aacSample = { unit: aacData.data, length: aacData.data.byteLength, dts: dts, pts: dts }; track.samples.push(aacSample); track.length += aacData.data.length; } else { Log.e(this.TAG, \`Flv: Unsupported AAC data type \${aacData.packetType}\`); } } else if (soundFormat === 2) { // MP3 if (!meta.codec) { // We need metadata for mp3 audio track, extract info from frame header let misc = this._parseMP3AudioData(arrayBuffer, dataOffset + 1, dataSize - 1, true); if (misc == undefined) { return; } meta.audioSampleRate = misc.samplingRate; meta.channelCount = misc.channelCount; meta.codec = misc.codec; meta.originalCodec = misc.originalCodec; // The decode result of an mp3 sample is 1152 PCM samples meta.refSampleDuration = 1152 / meta.audioSampleRate * meta.timescale; Log.v(this.TAG, 'Parsed MPEG Audio Frame Header'); this._audioInitialMetadataDispatched = true; this._onTrackMetadata('audio', meta); let mi = this._mediaInfo; mi.audioCodec = meta.codec; mi.audioSampleRate = meta.audioSampleRate; mi.audioChannelCount = meta.channelCount; mi.audioDataRate = misc.bitRate; if (mi.hasVideo) { if (mi.videoCodec != null) { mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; } } else { mi.mimeType = 'video/x-flv; codecs="' + mi.audioCodec + '"'; } if (mi.isComplete()) { this._onMediaInfo(mi); } } // This packet is always a valid audio packet, extract it let data = this._parseMP3AudioData(arrayBuffer, dataOffset + 1, dataSize - 1, false); if (data == undefined) { return; } let dts = this._timestampBase + tagTimestamp; let mp3Sample = { unit: data, length: data.byteLength, dts: dts, pts: dts }; track.samples.push(mp3Sample); track.length += data.length; } } _parseAACAudioData(arrayBuffer, dataOffset, dataSize) { if (dataSize <= 1) { Log.w(this.TAG, 'Flv: Invalid AAC packet, missing AACPacketType or/and Data!'); return; } let result = {}; let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); result.packetType = array[0]; if (array[0] === 0) { result.data = this._parseAACAudioSpecificConfig(arrayBuffer, dataOffset + 1, dataSize - 1); } else { result.data = array.subarray(1); } return result; } _parseAACAudioSpecificConfig(arrayBuffer, dataOffset, dataSize) { let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); let config = null; /* Audio Object Type: 0: Null 1: AAC Main 2: AAC LC 3: AAC SSR (Scalable Sample Rate) 4: AAC LTP (Long Term Prediction) 5: HE-AAC / SBR (Spectral Band Replication) 6: AAC Scalable */ let audioObjectType = 0; let originalAudioObjectType = 0; let audioExtensionObjectType = null; let samplingIndex = 0; let extensionSamplingIndex = null; // 5 bits audioObjectType = originalAudioObjectType = array[0] >>> 3; // 4 bits samplingIndex = ((array[0] & 0x07) << 1) | (array[1] >>> 7); if (samplingIndex < 0 || samplingIndex >= this._mpegSamplingRates.length) { this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: AAC invalid sampling frequency index!'); return; } let samplingFrequence = this._mpegSamplingRates[samplingIndex]; // 4 bits let channelConfig = (array[1] & 0x78) >>> 3; if (channelConfig < 0 || channelConfig >= 8) { this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: AAC invalid channel configuration'); return; } if (audioObjectType === 5) { // HE-AAC? // 4 bits extensionSamplingIndex = ((array[1] & 0x07) << 1) | (array[2] >>> 7); // 5 bits audioExtensionObjectType = (array[2] & 0x7C) >>> 2; } // workarounds for various browsers let userAgent = navigator.userAgent.toLowerCase(); if (userAgent.indexOf('firefox') !== -1) { // firefox: use SBR (HE-AAC) if freq less than 24kHz if (samplingIndex >= 6) { audioObjectType = 5; config = new Array(4); extensionSamplingIndex = samplingIndex - 3; } else { // use LC-AAC audioObjectType = 2; config = new Array(2); extensionSamplingIndex = samplingIndex; } } else if (userAgent.indexOf('android') !== -1) { // android: always use LC-AAC audioObjectType = 2; config = new Array(2); extensionSamplingIndex = samplingIndex; } else { // for other browsers, e.g. chrome... // Always use HE-AAC to make it easier to switch aac codec profile audioObjectType = 5; extensionSamplingIndex = samplingIndex; config = new Array(4); if (samplingIndex >= 6) { extensionSamplingIndex = samplingIndex - 3; } else if (channelConfig === 1) { // Mono channel audioObjectType = 2; config = new Array(2); extensionSamplingIndex = samplingIndex; } } config[0] = audioObjectType << 3; config[0] |= (samplingIndex & 0x0F) >>> 1; config[1] = (samplingIndex & 0x0F) << 7; config[1] |= (channelConfig & 0x0F) << 3; if (audioObjectType === 5) { config[1] |= ((extensionSamplingIndex & 0x0F) >>> 1); config[2] = (extensionSamplingIndex & 0x01) << 7; // extended audio object type: force to 2 (LC-AAC) config[2] |= (2 << 2); config[3] = 0; } return { // configRaw: added by qli5 configRaw: array, config: config, samplingRate: samplingFrequence, channelCount: channelConfig, codec: 'mp4a.40.' + audioObjectType, originalCodec: 'mp4a.40.' + originalAudioObjectType }; } _parseMP3AudioData(arrayBuffer, dataOffset, dataSize, requestHeader) { if (dataSize < 4) { Log.w(this.TAG, 'Flv: Invalid MP3 packet, header missing!'); return; } let le = this._littleEndian; let array = new Uint8Array(arrayBuffer, dataOffset, dataSize); let result = null; if (requestHeader) { if (array[0] !== 0xFF) { return; } let ver = (array[1] >>> 3) & 0x03; let layer = (array[1] & 0x06) >> 1; let bitrate_index = (array[2] & 0xF0) >>> 4; let sampling_freq_index = (array[2] & 0x0C) >>> 2; let channel_mode = (array[3] >>> 6) & 0x03; let channel_count = channel_mode !== 3 ? 2 : 1; let sample_rate = 0; let bit_rate = 0; let object_type = 34; // Layer-3, listed in MPEG-4 Audio Object Types let codec = 'mp3'; switch (ver) { case 0: // MPEG 2.5 sample_rate = this._mpegAudioV25SampleRateTable[sampling_freq_index]; break; case 2: // MPEG 2 sample_rate = this._mpegAudioV20SampleRateTable[sampling_freq_index]; break; case 3: // MPEG 1 sample_rate = this._mpegAudioV10SampleRateTable[sampling_freq_index]; break; } switch (layer) { case 1: // Layer 3 object_type = 34; if (bitrate_index < this._mpegAudioL3BitRateTable.length) { bit_rate = this._mpegAudioL3BitRateTable[bitrate_index]; } break; case 2: // Layer 2 object_type = 33; if (bitrate_index < this._mpegAudioL2BitRateTable.length) { bit_rate = this._mpegAudioL2BitRateTable[bitrate_index]; } break; case 3: // Layer 1 object_type = 32; if (bitrate_index < this._mpegAudioL1BitRateTable.length) { bit_rate = this._mpegAudioL1BitRateTable[bitrate_index]; } break; } result = { bitRate: bit_rate, samplingRate: sample_rate, channelCount: channel_count, codec: codec, originalCodec: codec }; } else { result = array; } return result; } _parseVideoData(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition) { if (dataSize <= 1) { Log.w(this.TAG, 'Flv: Invalid video packet, missing VideoData payload!'); return; } if (this._hasVideoFlagOverrided === true && this._hasVideo === false) { // If hasVideo: false indicated explicitly in MediaDataSource, // Ignore all the video packets return; } let spec = (new Uint8Array(arrayBuffer, dataOffset, dataSize))[0]; let frameType = (spec & 240) >>> 4; let codecId = spec & 15; if (codecId !== 7) { this._onError(DemuxErrors.CODEC_UNSUPPORTED, \`Flv: Unsupported codec in video frame: \${codecId}\`); return; } this._parseAVCVideoPacket(arrayBuffer, dataOffset + 1, dataSize - 1, tagTimestamp, tagPosition, frameType); } _parseAVCVideoPacket(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition, frameType) { if (dataSize < 4) { Log.w(this.TAG, 'Flv: Invalid AVC packet, missing AVCPacketType or/and CompositionTime'); return; } let le = this._littleEndian; let v = new DataView(arrayBuffer, dataOffset, dataSize); let packetType = v.getUint8(0); let cts = v.getUint32(0, !le) & 0x00FFFFFF; if (packetType === 0) { // AVCDecoderConfigurationRecord this._parseAVCDecoderConfigurationRecord(arrayBuffer, dataOffset + 4, dataSize - 4); } else if (packetType === 1) { // One or more Nalus this._parseAVCVideoData(arrayBuffer, dataOffset + 4, dataSize - 4, tagTimestamp, tagPosition, frameType, cts); } else if (packetType === 2) { // empty, AVC end of sequence } else { this._onError(DemuxErrors.FORMAT_ERROR, \`Flv: Invalid video packet type \${packetType}\`); return; } } _parseAVCDecoderConfigurationRecord(arrayBuffer, dataOffset, dataSize) { if (dataSize < 7) { Log.w(this.TAG, 'Flv: Invalid AVCDecoderConfigurationRecord, lack of data!'); return; } let meta = this._videoMetadata; let track = this._videoTrack; let le = this._littleEndian; let v = new DataView(arrayBuffer, dataOffset, dataSize); if (!meta) { if (this._hasVideo === false && this._hasVideoFlagOverrided === false) { this._hasVideo = true; this._mediaInfo.hasVideo = true; } meta = this._videoMetadata = {}; meta.type = 'video'; meta.id = track.id; meta.timescale = this._timescale; meta.duration = this._duration; } else { if (typeof meta.avcc !== 'undefined') { Log.w(this.TAG, 'Found another AVCDecoderConfigurationRecord!'); } } let version = v.getUint8(0); // configurationVersion let avcProfile = v.getUint8(1); // avcProfileIndication let profileCompatibility = v.getUint8(2); // profile_compatibility let avcLevel = v.getUint8(3); // AVCLevelIndication if (version !== 1 || avcProfile === 0) { this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord'); return; } this._naluLengthSize = (v.getUint8(4) & 3) + 1; // lengthSizeMinusOne if (this._naluLengthSize !== 3 && this._naluLengthSize !== 4) { // holy shit!!! this._onError(DemuxErrors.FORMAT_ERROR, \`Flv: Strange NaluLengthSizeMinusOne: \${this._naluLengthSize - 1}\`); return; } let spsCount = v.getUint8(5) & 31; // numOfSequenceParameterSets if (spsCount === 0) { this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord: No SPS'); return; } else if (spsCount > 1) { Log.w(this.TAG, \`Flv: Strange AVCDecoderConfigurationRecord: SPS Count = \${spsCount}\`); } let offset = 6; for (let i = 0; i < spsCount; i++) { let len = v.getUint16(offset, !le); // sequenceParameterSetLength offset += 2; if (len === 0) { continue; } // Notice: Nalu without startcode header (00 00 00 01) let sps = new Uint8Array(arrayBuffer, dataOffset + offset, len); offset += len; let config = SPSParser.parseSPS(sps); if (i !== 0) { // ignore other sps's config continue; } meta.codecWidth = config.codec_size.width; meta.codecHeight = config.codec_size.height; meta.presentWidth = config.present_size.width; meta.presentHeight = config.present_size.height; meta.profile = config.profile_string; meta.level = config.level_string; meta.bitDepth = config.bit_depth; meta.chromaFormat = config.chroma_format; meta.sarRatio = config.sar_ratio; meta.frameRate = config.frame_rate; if (config.frame_rate.fixed === false || config.frame_rate.fps_num === 0 || config.frame_rate.fps_den === 0) { meta.frameRate = this._referenceFrameRate; } let fps_den = meta.frameRate.fps_den; let fps_num = meta.frameRate.fps_num; meta.refSampleDuration = meta.timescale * (fps_den / fps_num); let codecArray = sps.subarray(1, 4); let codecString = 'avc1.'; for (let j = 0; j < 3; j++) { let h = codecArray[j].toString(16); if (h.length < 2) { h = '0' + h; } codecString += h; } meta.codec = codecString; let mi = this._mediaInfo; mi.width = meta.codecWidth; mi.height = meta.codecHeight; mi.fps = meta.frameRate.fps; mi.profile = meta.profile; mi.level = meta.level; mi.chromaFormat = config.chroma_format_string; mi.sarNum = meta.sarRatio.width; mi.sarDen = meta.sarRatio.height; mi.videoCodec = codecString; if (mi.hasAudio) { if (mi.audioCodec != null) { mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + ',' + mi.audioCodec + '"'; } } else { mi.mimeType = 'video/x-flv; codecs="' + mi.videoCodec + '"'; } if (mi.isComplete()) { this._onMediaInfo(mi); } } let ppsCount = v.getUint8(offset); // numOfPictureParameterSets if (ppsCount === 0) { this._onError(DemuxErrors.FORMAT_ERROR, 'Flv: Invalid AVCDecoderConfigurationRecord: No PPS'); return; } else if (ppsCount > 1) { Log.w(this.TAG, \`Flv: Strange AVCDecoderConfigurationRecord: PPS Count = \${ppsCount}\`); } offset++; for (let i = 0; i < ppsCount; i++) { let len = v.getUint16(offset, !le); // pictureParameterSetLength offset += 2; if (len === 0) { continue; } // pps is useless for extracting video information offset += len; } meta.avcc = new Uint8Array(dataSize); meta.avcc.set(new Uint8Array(arrayBuffer, dataOffset, dataSize), 0); Log.v(this.TAG, 'Parsed AVCDecoderConfigurationRecord'); if (this._isInitialMetadataDispatched()) { // flush parsed frames if (this._dispatch && (this._audioTrack.length || this._videoTrack.length)) { this._onDataAvailable(this._audioTrack, this._videoTrack); } } else { this._videoInitialMetadataDispatched = true; } // notify new metadata this._dispatch = false; this._onTrackMetadata('video', meta); } _parseAVCVideoData(arrayBuffer, dataOffset, dataSize, tagTimestamp, tagPosition, frameType, cts) { let le = this._littleEndian; let v = new DataView(arrayBuffer, dataOffset, dataSize); let units = [], length = 0; let offset = 0; const lengthSize = this._naluLengthSize; let dts = this._timestampBase + tagTimestamp; let keyframe = (frameType === 1); // from FLV Frame Type constants let refIdc = 1; // added by qli5 while (offset < dataSize) { if (offset + 4 >= dataSize) { Log.w(this.TAG, \`Malformed Nalu near timestamp \${dts}, offset = \${offset}, dataSize = \${dataSize}\`); break; // data not enough for next Nalu } // Nalu with length-header (AVC1) let naluSize = v.getUint32(offset, !le); // Big-Endian read if (lengthSize === 3) { naluSize >>>= 8; } if (naluSize > dataSize - lengthSize) { Log.w(this.TAG, \`Malformed Nalus near timestamp \${dts}, NaluSize > DataSize!\`); return; } let unitType = v.getUint8(offset + lengthSize) & 0x1F; // added by qli5 refIdc = v.getUint8(offset + lengthSize) & 0x60; if (unitType === 5) { // IDR keyframe = true; } let data = new Uint8Array(arrayBuffer, dataOffset + offset, lengthSize + naluSize); let unit = { type: unitType, data: data }; units.push(unit); length += data.byteLength; offset += lengthSize + naluSize; } if (units.length) { let track = this._videoTrack; let avcSample = { units: units, length: length, isKeyframe: keyframe, refIdc: refIdc, dts: dts, cts: cts, pts: (dts + cts) }; if (keyframe) { avcSample.fileposition = tagPosition; } track.samples.push(avcSample); track.length += length; } } } return FLVDemuxer; })(); const ASS = class { /** * Extract sections from ass string * @param {string} str * @returns {Object} - object from sections */ static extractSections(str) { const regex = /\\[(.*)\\]/g; let match; let matchArr = []; while ((match = regex.exec(str)) !== null) { matchArr.push({ name: match[1], index: match.index }); } let ret = {}; matchArr.forEach((match, i) => ret[match.name] = str.slice(match.index, matchArr[i + 1] && matchArr[i + 1].index)); return ret; } /** * Extract subtitle lines from section Events * @param {string} str * @returns {Array<Object>} - array of subtitle lines */ static extractSubtitleLines(str) { const lines = str.split('\\n'); if (lines[0] != '[Events]' && lines[0] != '[events]') throw new Error('ASSDemuxer: section is not [Events]'); if (lines[1].indexOf('Format:') != 0 && lines[1].indexOf('format:') != 0) throw new Error('ASSDemuxer: cannot find Format definition in section [Events]'); const format = lines[1].slice(lines[1].indexOf(':') + 1).split(',').map(e => e.trim()); return lines.slice(2).map(e => { let j = {}; e.replace(/[d|D]ialogue:\\s*/, '') .match(new RegExp(new Array(format.length - 1).fill('(.*?),').join('') + '(.*)')) .slice(1) .forEach((k, index) => j[format[index]] = k) return j; }); } /** * Create a new ASS Demuxer */ constructor() { this.info = ''; this.styles = ''; this.events = ''; this.eventsHeader = ''; this.pictures = ''; this.fonts = ''; this.lines = ''; } get header() { // return this.info + this.styles + this.eventsHeader; return this.info + this.styles; } /** * Load a file from an arraybuffer of a string * @param {(ArrayBuffer|string)} chunk */ parseFile(chunk) { const str = typeof chunk == 'string' ? chunk : new TextDecoder('utf-8').decode(chunk); for (let [i, j] of Object.entries(ASS.extractSections(str))) { if (i.match(/Script Info(?:mation)?/i)) this.info = j; else if (i.match(/V4\\+? Styles?/i)) this.styles = j; else if (i.match(/Events?/i)) this.events = j; else if (i.match(/Pictures?/i)) this.pictures = j; else if (i.match(/Fonts?/i)) this.fonts = j; } this.eventsHeader = this.events.split('\\n', 2).join('\\n') + '\\n'; this.lines = ASS.extractSubtitleLines(this.events); return this; } }; /** * The EMBL builder is from simple-ebml-builder * * Copyright 2017 ryiwamoto * * @author ryiwamoto * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject * to the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER * DEALINGS IN THE SOFTWARE. */ // const EBML = require('./ebml'); const EBML = (function e(t, n, r) { function s(o, u) { if (!n[o]) { if (!t[o]) { var a = typeof require == "function" && require; if (!u && a) return a(o, !0); if (i) return i(o, !0); var f = new Error("Cannot find module '" + o + "'"); throw f.code = "MODULE_NOT_FOUND", f } var l = n[o] = { exports: {} }; t[o][0].call(l.exports, function (e) { var n = t[o][1][e]; return s(n ? n : e) }, l, l.exports, e, t, n, r) } return n[o].exports } var i = typeof require == "function" && require; for (var o = 0; o < r.length; o++)s(r[o]); return s })({ 1: [function (require, module, exports) { let EBML = require('simple-ebml-builder'); EBML.float = num => new EBML.Value(EBML.float32bit(num)); EBML.int16 = num => new EBML.Value(EBML.int16Bit(num)); module.exports = EBML; }, { "simple-ebml-builder": 5 }], 2: [function (require, module, exports) { (function (global) { /** * lodash (Custom Build) <https://lodash.com/> * Build: \`lodash modularize exports="npm" -o ./\` * Copyright jQuery Foundation and other contributors <https://jquery.org/> * Released under MIT license <https://lodash.com/license> * Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE> * Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors */ /** Used as the \`TypeError\` message for "Functions" methods. */ var FUNC_ERROR_TEXT = 'Expected a function'; /** Used to stand-in for \`undefined\` hash values. */ var HASH_UNDEFINED = '__lodash_hash_undefined__'; /** \`Object#toString\` result references. */ var funcTag = '[object Function]', genTag = '[object GeneratorFunction]'; /** * Used to match \`RegExp\` * [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns). */ var reRegExpChar = /[\\\\^\$.*+?()[\\]{}|]/g; /** Used to detect host constructors (Safari). */ var reIsHostCtor = /^\\[object .+?Constructor\\]\$/; /** Detect free variable \`global\` from Node.js. */ var freeGlobal = typeof global == 'object' && global && global.Object === Object && global; /** Detect free variable \`self\`. */ var freeSelf = typeof self == 'object' && self && self.Object === Object && self; /** Used as a reference to the global object. */ var root = freeGlobal || freeSelf || Function('return this')(); /** * Gets the value at \`key\` of \`object\`. * * @private * @param {Object} [object] The object to query. * @param {string} key The key of the property to get. * @returns {*} Returns the property value. */ function getValue(object, key) { return object == null ? undefined : object[key]; } /** * Checks if \`value\` is a host object in IE < 9. * * @private * @param {*} value The value to check. * @returns {boolean} Returns \`true\` if \`value\` is a host object, else \`false\`. */ function isHostObject(value) { // Many host objects are \`Object\` objects that can coerce to strings // despite having improperly defined \`toString\` methods. var result = false; if (value != null && typeof value.toString != 'function') { try { result = !!(value + ''); } catch (e) { } } return result; } /** Used for built-in method references. */ var arrayProto = Array.prototype, funcProto = Function.prototype, objectProto = Object.prototype; /** Used to detect overreaching core-js shims. */ var coreJsData = root['__core-js_shared__']; /** Used to detect methods masquerading as native. */ var maskSrcKey = (function () { var uid = /[^.]+\$/.exec(coreJsData && coreJsData.keys && coreJsData.keys.IE_PROTO || ''); return uid ? ('Symbol(src)_1.' + uid) : ''; }()); /** Used to resolve the decompiled source of functions. */ var funcToString = funcProto.toString; /** Used to check objects for own properties. */ var hasOwnProperty = objectProto.hasOwnProperty; /** * Used to resolve the * [\`toStringTag\`](http://ecma-international.org/ecma-262/7.0/#sec-object.prototype.tostring) * of values. */ var objectToString = objectProto.toString; /** Used to detect if a method is native. */ var reIsNative = RegExp('^' + funcToString.call(hasOwnProperty).replace(reRegExpChar, '\\\\\$&') .replace(/hasOwnProperty|(function).*?(?=\\\\\\()| for .+?(?=\\\\\\])/g, '\$1.*?') + '\$' ); /** Built-in value references. */ var splice = arrayProto.splice; /* Built-in method references that are verified to be native. */ var Map = getNative(root, 'Map'), nativeCreate = getNative(Object, 'create'); /** * Creates a hash object. * * @private * @constructor * @param {Array} [entries] The key-value pairs to cache. */ function Hash(entries) { var index = -1, length = entries ? entries.length : 0; this.clear(); while (++index < length) { var entry = entries[index]; this.set(entry[0], entry[1]); } } /** * Removes all key-value entries from the hash. * * @private * @name clear * @memberOf Hash */ function hashClear() { this.__data__ = nativeCreate ? nativeCreate(null) : {}; } /** * Removes \`key\` and its value from the hash. * * @private * @name delete * @memberOf Hash * @param {Object} hash The hash to modify. * @param {string} key The key of the value to remove. * @returns {boolean} Returns \`true\` if the entry was removed, else \`false\`. */ function hashDelete(key) { return this.has(key) && delete this.__data__[key]; } /** * Gets the hash value for \`key\`. * * @private * @name get * @memberOf Hash * @param {string} key The key of the value to get. * @returns {*} Returns the entry value. */ function hashGet(key) { var data = this.__data__; if (nativeCreate) { var result = data[key]; return result === HASH_UNDEFINED ? undefined : result; } return hasOwnProperty.call(data, key) ? data[key] : undefined; } /** * Checks if a hash value for \`key\` exists. * * @private * @name has * @memberOf Hash * @param {string} key The key of the entry to check. * @returns {boolean} Returns \`true\` if an entry for \`key\` exists, else \`false\`. */ function hashHas(key) { var data = this.__data__; return nativeCreate ? data[key] !== undefined : hasOwnProperty.call(data, key); } /** * Sets the hash \`key\` to \`value\`. * * @private * @name set * @memberOf Hash * @param {string} key The key of the value to set. * @param {*} value The value to set. * @returns {Object} Returns the hash instance. */ function hashSet(key, value) { var data = this.__data__; data[key] = (nativeCreate && value === undefined) ? HASH_UNDEFINED : value; return this; } // Add methods to \`Hash\`. Hash.prototype.clear = hashClear; Hash.prototype['delete'] = hashDelete; Hash.prototype.get = hashGet; Hash.prototype.has = hashHas; Hash.prototype.set = hashSet; /** * Creates an list cache object. * * @private * @constructor * @param {Array} [entries] The key-value pairs to cache. */ function ListCache(entries) { var index = -1, length = entries ? entries.length : 0; this.clear(); while (++index < length) { var entry = entries[index]; this.set(entry[0], entry[1]); } } /** * Removes all key-value entries from the list cache. * * @private * @name clear * @memberOf ListCache */ function listCacheClear() { this.__data__ = []; } /** * Removes \`key\` and its value from the list cache. * * @private * @name delete * @memberOf ListCache * @param {string} key The key of the value to remove. * @returns {boolean} Returns \`true\` if the entry was removed, else \`false\`. */ function listCacheDelete(key) { var data = this.__data__, index = assocIndexOf(data, key); if (index < 0) { return false; } var lastIndex = data.length - 1; if (index == lastIndex) { data.pop(); } else { splice.call(data, index, 1); } return true; } /** * Gets the list cache value for \`key\`. * * @private * @name get * @memberOf ListCache * @param {string} key The key of the value to get. * @returns {*} Returns the entry value. */ function listCacheGet(key) { var data = this.__data__, index = assocIndexOf(data, key); return index < 0 ? undefined : data[index][1]; } /** * Checks if a list cache value for \`key\` exists. * * @private * @name has * @memberOf ListCache * @param {string} key The key of the entry to check. * @returns {boolean} Returns \`true\` if an entry for \`key\` exists, else \`false\`. */ function listCacheHas(key) { return assocIndexOf(this.__data__, key) > -1; } /** * Sets the list cache \`key\` to \`value\`. * * @private * @name set * @memberOf ListCache * @param {string} key The key of the value to set. * @param {*} value The value to set. * @returns {Object} Returns the list cache instance. */ function listCacheSet(key, value) { var data = this.__data__, index = assocIndexOf(data, key); if (index < 0) { data.push([key, value]); } else { data[index][1] = value; } return this; } // Add methods to \`ListCache\`. ListCache.prototype.clear = listCacheClear; ListCache.prototype['delete'] = listCacheDelete; ListCache.prototype.get = listCacheGet; ListCache.prototype.has = listCacheHas; ListCache.prototype.set = listCacheSet; /** * Creates a map cache object to store key-value pairs. * * @private * @constructor * @param {Array} [entries] The key-value pairs to cache. */ function MapCache(entries) { var index = -1, length = entries ? entries.length : 0; this.clear(); while (++index < length) { var entry = entries[index]; this.set(entry[0], entry[1]); } } /** * Removes all key-value entries from the map. * * @private * @name clear * @memberOf MapCache */ function mapCacheClear() { this.__data__ = { 'hash': new Hash, 'map': new (Map || ListCache), 'string': new Hash }; } /** * Removes \`key\` and its value from the map. * * @private * @name delete * @memberOf MapCache * @param {string} key The key of the value to remove. * @returns {boolean} Returns \`true\` if the entry was removed, else \`false\`. */ function mapCacheDelete(key) { return getMapData(this, key)['delete'](key); } /** * Gets the map value for \`key\`. * * @private * @name get * @memberOf MapCache * @param {string} key The key of the value to get. * @returns {*} Returns the entry value. */ function mapCacheGet(key) { return getMapData(this, key).get(key); } /** * Checks if a map value for \`key\` exists. * * @private * @name has * @memberOf MapCache * @param {string} key The key of the entry to check. * @returns {boolean} Returns \`true\` if an entry for \`key\` exists, else \`false\`. */ function mapCacheHas(key) { return getMapData(this, key).has(key); } /** * Sets the map \`key\` to \`value\`. * * @private * @name set * @memberOf MapCache * @param {string} key The key of the value to set. * @param {*} value The value to set. * @returns {Object} Returns the map cache instance. */ function mapCacheSet(key, value) { getMapData(this, key).set(key, value); return this; } // Add methods to \`MapCache\`. MapCache.prototype.clear = mapCacheClear; MapCache.prototype['delete'] = mapCacheDelete; MapCache.prototype.get = mapCacheGet; MapCache.prototype.has = mapCacheHas; MapCache.prototype.set = mapCacheSet; /** * Gets the index at which the \`key\` is found in \`array\` of key-value pairs. * * @private * @param {Array} array The array to inspect. * @param {*} key The key to search for. * @returns {number} Returns the index of the matched value, else \`-1\`. */ function assocIndexOf(array, key) { var length = array.length; while (length--) { if (eq(array[length][0], key)) { return length; } } return -1; } /** * The base implementation of \`_.isNative\` without bad shim checks. * * @private * @param {*} value The value to check. * @returns {boolean} Returns \`true\` if \`value\` is a native function, * else \`false\`. */ function baseIsNative(value) { if (!isObject(value) || isMasked(value)) { return false; } var pattern = (isFunction(value) || isHostObject(value)) ? reIsNative : reIsHostCtor; return pattern.test(toSource(value)); } /** * Gets the data for \`map\`. * * @private * @param {Object} map The map to query. * @param {string} key The reference key. * @returns {*} Returns the map data. */ function getMapData(map, key) { var data = map.__data__; return isKeyable(key) ? data[typeof key == 'string' ? 'string' : 'hash'] : data.map; } /** * Gets the native function at \`key\` of \`object\`. * * @private * @param {Object} object The object to query. * @param {string} key The key of the method to get. * @returns {*} Returns the function if it's native, else \`undefined\`. */ function getNative(object, key) { var value = getValue(object, key); return baseIsNative(value) ? value : undefined; } /** * Checks if \`value\` is suitable for use as unique object key. * * @private * @param {*} value The value to check. * @returns {boolean} Returns \`true\` if \`value\` is suitable, else \`false\`. */ function isKeyable(value) { var type = typeof value; return (type == 'string' || type == 'number' || type == 'symbol' || type == 'boolean') ? (value !== '__proto__') : (value === null); } /** * Checks if \`func\` has its source masked. * * @private * @param {Function} func The function to check. * @returns {boolean} Returns \`true\` if \`func\` is masked, else \`false\`. */ function isMasked(func) { return !!maskSrcKey && (maskSrcKey in func); } /** * Converts \`func\` to its source code. * * @private * @param {Function} func The function to process. * @returns {string} Returns the source code. */ function toSource(func) { if (func != null) { try { return funcToString.call(func); } catch (e) { } try { return (func + ''); } catch (e) { } } return ''; } /** * Creates a function that memoizes the result of \`func\`. If \`resolver\` is * provided, it determines the cache key for storing the result based on the * arguments provided to the memoized function. By default, the first argument * provided to the memoized function is used as the map cache key. The \`func\` * is invoked with the \`this\` binding of the memoized function. * * **Note:** The cache is exposed as the \`cache\` property on the memoized * function. Its creation may be customized by replacing the \`_.memoize.Cache\` * constructor with one whose instances implement the * [\`Map\`](http://ecma-international.org/ecma-262/7.0/#sec-properties-of-the-map-prototype-object) * method interface of \`delete\`, \`get\`, \`has\`, and \`set\`. * * @static * @memberOf _ * @since 0.1.0 * @category Function * @param {Function} func The function to have its output memoized. * @param {Function} [resolver] The function to resolve the cache key. * @returns {Function} Returns the new memoized function. * @example * * var object = { 'a': 1, 'b': 2 }; * var other = { 'c': 3, 'd': 4 }; * * var values = _.memoize(_.values); * values(object); * // => [1, 2] * * values(other); * // => [3, 4] * * object.a = 2; * values(object); * // => [1, 2] * * // Modify the result cache. * values.cache.set(object, ['a', 'b']); * values(object); * // => ['a', 'b'] * * // Replace \`_.memoize.Cache\`. * _.memoize.Cache = WeakMap; */ function memoize(func, resolver) { if (typeof func != 'function' || (resolver && typeof resolver != 'function')) { throw new TypeError(FUNC_ERROR_TEXT); } var memoized = function () { var args = arguments, key = resolver ? resolver.apply(this, args) : args[0], cache = memoized.cache; if (cache.has(key)) { return cache.get(key); } var result = func.apply(this, args); memoized.cache = cache.set(key, result); return result; }; memoized.cache = new (memoize.Cache || MapCache); return memoized; } // Assign cache to \`_.memoize\`. memoize.Cache = MapCache; /** * Performs a * [\`SameValueZero\`](http://ecma-international.org/ecma-262/7.0/#sec-samevaluezero) * comparison between two values to determine if they are equivalent. * * @static * @memberOf _ * @since 4.0.0 * @category Lang * @param {*} value The value to compare. * @param {*} other The other value to compare. * @returns {boolean} Returns \`true\` if the values are equivalent, else \`false\`. * @example * * var object = { 'a': 1 }; * var other = { 'a': 1 }; * * _.eq(object, object); * // => true * * _.eq(object, other); * // => false * * _.eq('a', 'a'); * // => true * * _.eq('a', Object('a')); * // => false * * _.eq(NaN, NaN); * // => true */ function eq(value, other) { return value === other || (value !== value && other !== other); } /** * Checks if \`value\` is classified as a \`Function\` object. * * @static * @memberOf _ * @since 0.1.0 * @category Lang * @param {*} value The value to check. * @returns {boolean} Returns \`true\` if \`value\` is a function, else \`false\`. * @example * * _.isFunction(_); * // => true * * _.isFunction(/abc/); * // => false */ function isFunction(value) { // The use of \`Object#toString\` avoids issues with the \`typeof\` operator // in Safari 8-9 which returns 'object' for typed array and other constructors. var tag = isObject(value) ? objectToString.call(value) : ''; return tag == funcTag || tag == genTag; } /** * Checks if \`value\` is the * [language type](http://www.ecma-international.org/ecma-262/7.0/#sec-ecmascript-language-types) * of \`Object\`. (e.g. arrays, functions, objects, regexes, \`new Number(0)\`, and \`new String('')\`) * * @static * @memberOf _ * @since 0.1.0 * @category Lang * @param {*} value The value to check. * @returns {boolean} Returns \`true\` if \`value\` is an object, else \`false\`. * @example * * _.isObject({}); * // => true * * _.isObject([1, 2, 3]); * // => true * * _.isObject(_.noop); * // => true * * _.isObject(null); * // => false */ function isObject(value) { var type = typeof value; return !!value && (type == 'object' || type == 'function'); } module.exports = memoize; }).call(this, typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : {}) }, {}], 3: [function (require, module, exports) { "use strict"; var memoize = require("lodash.memoize"); var typedArrayUtils_1 = require("./typedArrayUtils"); var Value = (function () { function Value(bytes) { this.bytes = bytes; } Value.prototype.write = function (buf, pos) { buf.set(this.bytes, pos); return pos + this.bytes.length; }; Value.prototype.countSize = function () { return this.bytes.length; }; return Value; }()); exports.Value = Value; var Element = (function () { function Element(id, children, isSizeUnknown) { this.id = id; this.children = children; var bodySize = this.children.reduce(function (p, c) { return p + c.countSize(); }, 0); this.sizeMetaData = isSizeUnknown ? exports.UNKNOWN_SIZE : exports.vintEncode(typedArrayUtils_1.numberToByteArray(bodySize, exports.getEBMLByteLength(bodySize))); this.size = this.id.length + this.sizeMetaData.length + bodySize; } Element.prototype.write = function (buf, pos) { buf.set(this.id, pos); buf.set(this.sizeMetaData, pos + this.id.length); return this.children.reduce(function (p, c) { return c.write(buf, p); }, pos + this.id.length + this.sizeMetaData.length); }; Element.prototype.countSize = function () { return this.size; }; return Element; }()); exports.Element = Element; exports.bytes = memoize(function (data) { return new Value(data); }); exports.number = memoize(function (num) { return exports.bytes(typedArrayUtils_1.numberToByteArray(num)); }); exports.vintEncodedNumber = memoize(function (num) { return exports.bytes(exports.vintEncode(typedArrayUtils_1.numberToByteArray(num, exports.getEBMLByteLength(num)))); }); exports.string = memoize(function (str) { return exports.bytes(typedArrayUtils_1.stringToByteArray(str)); }); exports.element = function (id, child) { return new Element(id, Array.isArray(child) ? child : [child], false); }; exports.unknownSizeElement = function (id, child) { return new Element(id, Array.isArray(child) ? child : [child], true); }; exports.build = function (v) { var b = new Uint8Array(v.countSize()); v.write(b, 0); return b; }; exports.getEBMLByteLength = function (num) { if (num < 0) { throw new Error("EBML.getEBMLByteLength: negative number not implemented"); } else if (num < 0x7f) { return 1; } else if (num < 0x3fff) { return 2; } else if (num < 0x1fffff) { return 3; } else if (num < 0xfffffff) { return 4; } else if (num < 0x7ffffffff) { return 5; } else if (num < 0x3ffffffffff) { return 6; } else if (num < 0x1ffffffffffff) { return 7; } else if (num < 0x20000000000000) { return 8; } else if (num < 0xffffffffffffff) { throw new Error("EBMLgetEBMLByteLength: number exceeds Number.MAX_SAFE_INTEGER"); } else { throw new Error("EBMLgetEBMLByteLength: data size must be less than or equal to " + (Math.pow(2, 56) - 2)); } }; exports.UNKNOWN_SIZE = new Uint8Array([0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); exports.vintEncode = function (byteArray) { byteArray[0] = exports.getSizeMask(byteArray.length) | byteArray[0]; return byteArray; }; exports.getSizeMask = function (byteLength) { return 0x80 >> (byteLength - 1); }; }, { "./typedArrayUtils": 6, "lodash.memoize": 2 }], 4: [function (require, module, exports) { "use strict"; /** * @see https://www.matroska.org/technical/specs/index.html */ exports.ID = { EBML: Uint8Array.of(0x1A, 0x45, 0xDF, 0xA3), EBMLVersion: Uint8Array.of(0x42, 0x86), EBMLReadVersion: Uint8Array.of(0x42, 0xF7), EBMLMaxIDLength: Uint8Array.of(0x42, 0xF2), EBMLMaxSizeLength: Uint8Array.of(0x42, 0xF3), DocType: Uint8Array.of(0x42, 0x82), DocTypeVersion: Uint8Array.of(0x42, 0x87), DocTypeReadVersion: Uint8Array.of(0x42, 0x85), Void: Uint8Array.of(0xEC), CRC32: Uint8Array.of(0xBF), Segment: Uint8Array.of(0x18, 0x53, 0x80, 0x67), SeekHead: Uint8Array.of(0x11, 0x4D, 0x9B, 0x74), Seek: Uint8Array.of(0x4D, 0xBB), SeekID: Uint8Array.of(0x53, 0xAB), SeekPosition: Uint8Array.of(0x53, 0xAC), Info: Uint8Array.of(0x15, 0x49, 0xA9, 0x66), SegmentUID: Uint8Array.of(0x73, 0xA4), SegmentFilename: Uint8Array.of(0x73, 0x84), PrevUID: Uint8Array.of(0x3C, 0xB9, 0x23), PrevFilename: Uint8Array.of(0x3C, 0x83, 0xAB), NextUID: Uint8Array.of(0x3E, 0xB9, 0x23), NextFilename: Uint8Array.of(0x3E, 0x83, 0xBB), SegmentFamily: Uint8Array.of(0x44, 0x44), ChapterTranslate: Uint8Array.of(0x69, 0x24), ChapterTranslateEditionUID: Uint8Array.of(0x69, 0xFC), ChapterTranslateCodec: Uint8Array.of(0x69, 0xBF), ChapterTranslateID: Uint8Array.of(0x69, 0xA5), TimecodeScale: Uint8Array.of(0x2A, 0xD7, 0xB1), Duration: Uint8Array.of(0x44, 0x89), DateUTC: Uint8Array.of(0x44, 0x61), Title: Uint8Array.of(0x7B, 0xA9), MuxingApp: Uint8Array.of(0x4D, 0x80), WritingApp: Uint8Array.of(0x57, 0x41), Cluster: Uint8Array.of(0x1F, 0x43, 0xB6, 0x75), Timecode: Uint8Array.of(0xE7), SilentTracks: Uint8Array.of(0x58, 0x54), SilentTrackNumber: Uint8Array.of(0x58, 0xD7), Position: Uint8Array.of(0xA7), PrevSize: Uint8Array.of(0xAB), SimpleBlock: Uint8Array.of(0xA3), BlockGroup: Uint8Array.of(0xA0), Block: Uint8Array.of(0xA1), BlockAdditions: Uint8Array.of(0x75, 0xA1), BlockMore: Uint8Array.of(0xA6), BlockAddID: Uint8Array.of(0xEE), BlockAdditional: Uint8Array.of(0xA5), BlockDuration: Uint8Array.of(0x9B), ReferencePriority: Uint8Array.of(0xFA), ReferenceBlock: Uint8Array.of(0xFB), CodecState: Uint8Array.of(0xA4), DiscardPadding: Uint8Array.of(0x75, 0xA2), Slices: Uint8Array.of(0x8E), TimeSlice: Uint8Array.of(0xE8), LaceNumber: Uint8Array.of(0xCC), Tracks: Uint8Array.of(0x16, 0x54, 0xAE, 0x6B), TrackEntry: Uint8Array.of(0xAE), TrackNumber: Uint8Array.of(0xD7), TrackUID: Uint8Array.of(0x73, 0xC5), TrackType: Uint8Array.of(0x83), FlagEnabled: Uint8Array.of(0xB9), FlagDefault: Uint8Array.of(0x88), FlagForced: Uint8Array.of(0x55, 0xAA), FlagLacing: Uint8Array.of(0x9C), MinCache: Uint8Array.of(0x6D, 0xE7), MaxCache: Uint8Array.of(0x6D, 0xF8), DefaultDuration: Uint8Array.of(0x23, 0xE3, 0x83), DefaultDecodedFieldDuration: Uint8Array.of(0x23, 0x4E, 0x7A), MaxBlockAdditionID: Uint8Array.of(0x55, 0xEE), Name: Uint8Array.of(0x53, 0x6E), Language: Uint8Array.of(0x22, 0xB5, 0x9C), CodecID: Uint8Array.of(0x86), CodecPrivate: Uint8Array.of(0x63, 0xA2), CodecName: Uint8Array.of(0x25, 0x86, 0x88), AttachmentLink: Uint8Array.of(0x74, 0x46), CodecDecodeAll: Uint8Array.of(0xAA), TrackOverlay: Uint8Array.of(0x6F, 0xAB), CodecDelay: Uint8Array.of(0x56, 0xAA), SeekPreRoll: Uint8Array.of(0x56, 0xBB), TrackTranslate: Uint8Array.of(0x66, 0x24), TrackTranslateEditionUID: Uint8Array.of(0x66, 0xFC), TrackTranslateCodec: Uint8Array.of(0x66, 0xBF), TrackTranslateTrackID: Uint8Array.of(0x66, 0xA5), Video: Uint8Array.of(0xE0), FlagInterlaced: Uint8Array.of(0x9A), FieldOrder: Uint8Array.of(0x9D), StereoMode: Uint8Array.of(0x53, 0xB8), AlphaMode: Uint8Array.of(0x53, 0xC0), PixelWidth: Uint8Array.of(0xB0), PixelHeight: Uint8Array.of(0xBA), PixelCropBottom: Uint8Array.of(0x54, 0xAA), PixelCropTop: Uint8Array.of(0x54, 0xBB), PixelCropLeft: Uint8Array.of(0x54, 0xCC), PixelCropRight: Uint8Array.of(0x54, 0xDD), DisplayWidth: Uint8Array.of(0x54, 0xB0), DisplayHeight: Uint8Array.of(0x54, 0xBA), DisplayUnit: Uint8Array.of(0x54, 0xB2), AspectRatioType: Uint8Array.of(0x54, 0xB3), ColourSpace: Uint8Array.of(0x2E, 0xB5, 0x24), Colour: Uint8Array.of(0x55, 0xB0), MatrixCoefficients: Uint8Array.of(0x55, 0xB1), BitsPerChannel: Uint8Array.of(0x55, 0xB2), ChromaSubsamplingHorz: Uint8Array.of(0x55, 0xB3), ChromaSubsamplingVert: Uint8Array.of(0x55, 0xB4), CbSubsamplingHorz: Uint8Array.of(0x55, 0xB5), CbSubsamplingVert: Uint8Array.of(0x55, 0xB6), ChromaSitingHorz: Uint8Array.of(0x55, 0xB7), ChromaSitingVert: Uint8Array.of(0x55, 0xB8), Range: Uint8Array.of(0x55, 0xB9), TransferCharacteristics: Uint8Array.of(0x55, 0xBA), Primaries: Uint8Array.of(0x55, 0xBB), MaxCLL: Uint8Array.of(0x55, 0xBC), MaxFALL: Uint8Array.of(0x55, 0xBD), MasteringMetadata: Uint8Array.of(0x55, 0xD0), PrimaryRChromaticityX: Uint8Array.of(0x55, 0xD1), PrimaryRChromaticityY: Uint8Array.of(0x55, 0xD2), PrimaryGChromaticityX: Uint8Array.of(0x55, 0xD3), PrimaryGChromaticityY: Uint8Array.of(0x55, 0xD4), PrimaryBChromaticityX: Uint8Array.of(0x55, 0xD5), PrimaryBChromaticityY: Uint8Array.of(0x55, 0xD6), WhitePointChromaticityX: Uint8Array.of(0x55, 0xD7), WhitePointChromaticityY: Uint8Array.of(0x55, 0xD8), LuminanceMax: Uint8Array.of(0x55, 0xD9), LuminanceMin: Uint8Array.of(0x55, 0xDA), Audio: Uint8Array.of(0xE1), SamplingFrequency: Uint8Array.of(0xB5), OutputSamplingFrequency: Uint8Array.of(0x78, 0xB5), Channels: Uint8Array.of(0x9F), BitDepth: Uint8Array.of(0x62, 0x64), TrackOperation: Uint8Array.of(0xE2), TrackCombinePlanes: Uint8Array.of(0xE3), TrackPlane: Uint8Array.of(0xE4), TrackPlaneUID: Uint8Array.of(0xE5), TrackPlaneType: Uint8Array.of(0xE6), TrackJoinBlocks: Uint8Array.of(0xE9), TrackJoinUID: Uint8Array.of(0xED), ContentEncodings: Uint8Array.of(0x6D, 0x80), ContentEncoding: Uint8Array.of(0x62, 0x40), ContentEncodingOrder: Uint8Array.of(0x50, 0x31), ContentEncodingScope: Uint8Array.of(0x50, 0x32), ContentEncodingType: Uint8Array.of(0x50, 0x33), ContentCompression: Uint8Array.of(0x50, 0x34), ContentCompAlgo: Uint8Array.of(0x42, 0x54), ContentCompSettings: Uint8Array.of(0x42, 0x55), ContentEncryption: Uint8Array.of(0x50, 0x35), ContentEncAlgo: Uint8Array.of(0x47, 0xE1), ContentEncKeyID: Uint8Array.of(0x47, 0xE2), ContentSignature: Uint8Array.of(0x47, 0xE3), ContentSigKeyID: Uint8Array.of(0x47, 0xE4), ContentSigAlgo: Uint8Array.of(0x47, 0xE5), ContentSigHashAlgo: Uint8Array.of(0x47, 0xE6), Cues: Uint8Array.of(0x1C, 0x53, 0xBB, 0x6B), CuePoint: Uint8Array.of(0xBB), CueTime: Uint8Array.of(0xB3), CueTrackPositions: Uint8Array.of(0xB7), CueTrack: Uint8Array.of(0xF7), CueClusterPosition: Uint8Array.of(0xF1), CueRelativePosition: Uint8Array.of(0xF0), CueDuration: Uint8Array.of(0xB2), CueBlockNumber: Uint8Array.of(0x53, 0x78), CueCodecState: Uint8Array.of(0xEA), CueReference: Uint8Array.of(0xDB), CueRefTime: Uint8Array.of(0x96), Attachments: Uint8Array.of(0x19, 0x41, 0xA4, 0x69), AttachedFile: Uint8Array.of(0x61, 0xA7), FileDescription: Uint8Array.of(0x46, 0x7E), FileName: Uint8Array.of(0x46, 0x6E), FileMimeType: Uint8Array.of(0x46, 0x60), FileData: Uint8Array.of(0x46, 0x5C), FileUID: Uint8Array.of(0x46, 0xAE), Chapters: Uint8Array.of(0x10, 0x43, 0xA7, 0x70), EditionEntry: Uint8Array.of(0x45, 0xB9), EditionUID: Uint8Array.of(0x45, 0xBC), EditionFlagHidden: Uint8Array.of(0x45, 0xBD), EditionFlagDefault: Uint8Array.of(0x45, 0xDB), EditionFlagOrdered: Uint8Array.of(0x45, 0xDD), ChapterAtom: Uint8Array.of(0xB6), ChapterUID: Uint8Array.of(0x73, 0xC4), ChapterStringUID: Uint8Array.of(0x56, 0x54), ChapterTimeStart: Uint8Array.of(0x91), ChapterTimeEnd: Uint8Array.of(0x92), ChapterFlagHidden: Uint8Array.of(0x98), ChapterFlagEnabled: Uint8Array.of(0x45, 0x98), ChapterSegmentUID: Uint8Array.of(0x6E, 0x67), ChapterSegmentEditionUID: Uint8Array.of(0x6E, 0xBC), ChapterPhysicalEquiv: Uint8Array.of(0x63, 0xC3), ChapterTrack: Uint8Array.of(0x8F), ChapterTrackNumber: Uint8Array.of(0x89), ChapterDisplay: Uint8Array.of(0x80), ChapString: Uint8Array.of(0x85), ChapLanguage: Uint8Array.of(0x43, 0x7C), ChapCountry: Uint8Array.of(0x43, 0x7E), ChapProcess: Uint8Array.of(0x69, 0x44), ChapProcessCodecID: Uint8Array.of(0x69, 0x55), ChapProcessPrivate: Uint8Array.of(0x45, 0x0D), ChapProcessCommand: Uint8Array.of(0x69, 0x11), ChapProcessTime: Uint8Array.of(0x69, 0x22), ChapProcessData: Uint8Array.of(0x69, 0x33), Tags: Uint8Array.of(0x12, 0x54, 0xC3, 0x67), Tag: Uint8Array.of(0x73, 0x73), Targets: Uint8Array.of(0x63, 0xC0), TargetTypeValue: Uint8Array.of(0x68, 0xCA), TargetType: Uint8Array.of(0x63, 0xCA), TagTrackUID: Uint8Array.of(0x63, 0xC5), TagEditionUID: Uint8Array.of(0x63, 0xC9), TagChapterUID: Uint8Array.of(0x63, 0xC4), TagAttachmentUID: Uint8Array.of(0x63, 0xC6), SimpleTag: Uint8Array.of(0x67, 0xC8), TagName: Uint8Array.of(0x45, 0xA3), TagLanguage: Uint8Array.of(0x44, 0x7A), TagDefault: Uint8Array.of(0x44, 0x84), TagString: Uint8Array.of(0x44, 0x87), TagBinary: Uint8Array.of(0x44, 0x85), }; }, {}], 5: [function (require, module, exports) { "use strict"; function __export(m) { for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; } __export(require("./ebml")); __export(require("./id")); __export(require("./typedArrayUtils")); }, { "./ebml": 3, "./id": 4, "./typedArrayUtils": 6 }], 6: [function (require, module, exports) { "use strict"; var memoize = require("lodash.memoize"); exports.numberToByteArray = function (num, byteLength) { if (byteLength === void 0) byteLength = getNumberByteLength(num); var byteArray; if (byteLength == 1) { byteArray = new DataView(new ArrayBuffer(1)); byteArray.setUint8(0, num); } else if (byteLength == 2) { byteArray = new DataView(new ArrayBuffer(2)); byteArray.setUint16(0, num); } else if (byteLength == 3) { byteArray = new DataView(new ArrayBuffer(3)); byteArray.setUint8(0, num >> 16); byteArray.setUint16(1, num & 0xffff); } else if (byteLength == 4) { byteArray = new DataView(new ArrayBuffer(4)); byteArray.setUint32(0, num); } // 4GB (upper limit for int32) should be enough in most cases else if (/* byteLength == 5 && */num < 0xffffffff) { byteArray = new DataView(new ArrayBuffer(5)); byteArray.setUint32(1, num); } // Naive emulations of int64 bitwise opreators else if (byteLength == 5) { byteArray = new DataView(new ArrayBuffer(5)); byteArray.setUint8(0, num / 0x100000000 | 0); byteArray.setUint32(1, num % 0x100000000); } else if (byteLength == 6) { byteArray = new DataView(new ArrayBuffer(6)); byteArray.setUint16(0, num / 0x100000000 | 0); byteArray.setUint32(2, num % 0x100000000); } else if (byteLength == 7) { byteArray = new DataView(new ArrayBuffer(7)); byteArray.setUint8(0, num / 0x1000000000000 | 0); byteArray.setUint16(1, num / 0x100000000 & 0xffff); byteArray.setUint32(3, num % 0x100000000); } else if (byteLength == 8) { byteArray = new DataView(new ArrayBuffer(8)); byteArray.setUint32(0, num / 0x100000000 | 0); byteArray.setUint32(4, num % 0x100000000); } else { throw new Error("EBML.typedArrayUtils.numberToByteArray: byte length must be less than or equal to 8"); } return new Uint8Array(byteArray.buffer) }; exports.stringToByteArray = memoize(function (str) { return Uint8Array.from(Array.from(str).map(function (_) { return _.codePointAt(0); })); }); function getNumberByteLength(num) { if (num < 0) { throw new Error("EBML.typedArrayUtils.getNumberByteLength: negative number not implemented"); } else if (num < 0x100) { return 1; } else if (num < 0x10000) { return 2; } else if (num < 0x1000000) { return 3; } else if (num < 0x100000000) { return 4; } else if (num < 0x10000000000) { return 5; } else if (num < 0x1000000000000) { return 6; } else if (num < 0x20000000000000) { return 7; } else { throw new Error("EBML.typedArrayUtils.getNumberByteLength: number exceeds Number.MAX_SAFE_INTEGER"); } } exports.getNumberByteLength = getNumberByteLength; exports.int16Bit = memoize(function (num) { var ab = new ArrayBuffer(2); new DataView(ab).setInt16(0, num); return new Uint8Array(ab); }); exports.float32bit = memoize(function (num) { var ab = new ArrayBuffer(4); new DataView(ab).setFloat32(0, num); return new Uint8Array(ab); }); exports.dumpBytes = function (b) { return Array.from(new Uint8Array(b)).map(function (_) { return "0x" + _.toString(16); }).join(", "); }; }, { "lodash.memoize": 2 }] }, {}, [])(1); const MKV = class { constructor(config) { this.min = true; this.onprogress = null; Object.assign(this, config); this.segmentUID = MKV.randomBytes(16); this.trackUIDBase = Math.trunc(Math.random() * 2 ** 16); this.trackMetadata = { h264: null, aac: null, ass: null }; this.duration = 0; this.blocks = { h264: [], aac: [], ass: [] }; } static randomBytes(num) { return Array.from(new Array(num), () => Math.trunc(Math.random() * 256)); } static textToMS(str) { const [, h, mm, ss, ms10] = str.match(/(\\d+):(\\d+):(\\d+).(\\d+)/); return h * 3600000 + mm * 60000 + ss * 1000 + ms10 * 10; } static mimeToCodecID(str) { switch (str) { case 'avc1.640029': return 'V_MPEG4/ISO/AVC'; case 'mp4a.40.2': return 'A_AAC'; default: throw new Error(\`MKVRemuxer: unknown codec \${str}\`); } } static uint8ArrayConcat(...array) { // if (Array.isArray(array[0])) array = array[0]; if (array.length == 1) return array[0]; if (typeof Buffer != 'undefined') return Buffer.concat(array); const ret = new Uint8Array(array.reduce((i, j) => i.byteLength + j.byteLength)); let length = 0; for (let e of array) { ret.set(e, length); length += e.byteLength; } return ret; } addH264Metadata(h264) { this.trackMetadata.h264 = { codecId: MKV.mimeToCodecID(h264.codec), codecPrivate: h264.avcc, defaultDuration: h264.refSampleDuration * 1000000, pixelWidth: h264.codecWidth, pixelHeight: h264.codecHeight, displayWidth: h264.presentWidth, displayHeight: h264.presentHeight }; this.duration = Math.max(this.duration, h264.duration); } addAACMetadata(aac) { this.trackMetadata.aac = { codecId: MKV.mimeToCodecID(aac.originalCodec), codecPrivate: aac.configRaw, defaultDuration: aac.refSampleDuration * 1000000, samplingFrequence: aac.audioSampleRate, channels: aac.channelCount }; this.duration = Math.max(this.duration, aac.duration); } addASSMetadata(ass) { this.trackMetadata.ass = { codecId: 'S_TEXT/ASS', codecPrivate: new TextEncoder().encode(ass.header) }; } addH264Stream(h264) { this.blocks.h264 = this.blocks.h264.concat(h264.samples.map(e => ({ track: 1, frame: MKV.uint8ArrayConcat(...e.units.map(i => i.data)), isKeyframe: e.isKeyframe, discardable: Boolean(e.refIdc), timestamp: e.pts, simple: true, }))); } addAACStream(aac) { this.blocks.aac = this.blocks.aac.concat(aac.samples.map(e => ({ track: 2, frame: e.unit, timestamp: e.pts, simple: true, }))); } addASSStream(ass) { this.blocks.ass = this.blocks.ass.concat(ass.lines.map((e, i) => ({ track: 3, frame: new TextEncoder().encode(\`\${i},\${e['Layer'] || ''},\${e['Style'] || ''},\${e['Name'] || ''},\${e['MarginL'] || ''},\${e['MarginR'] || ''},\${e['MarginV'] || ''},\${e['Effect'] || ''},\${e['Text'] || ''}\`), timestamp: MKV.textToMS(e['Start']), duration: MKV.textToMS(e['End']) - MKV.textToMS(e['Start']), }))); } build() { return new Blob([ this.buildHeader(), this.buildBody() ]); } buildHeader() { return new Blob([EBML.build(EBML.element(EBML.ID.EBML, [ EBML.element(EBML.ID.EBMLVersion, EBML.number(1)), EBML.element(EBML.ID.EBMLReadVersion, EBML.number(1)), EBML.element(EBML.ID.EBMLMaxIDLength, EBML.number(4)), EBML.element(EBML.ID.EBMLMaxSizeLength, EBML.number(8)), EBML.element(EBML.ID.DocType, EBML.string('matroska')), EBML.element(EBML.ID.DocTypeVersion, EBML.number(4)), EBML.element(EBML.ID.DocTypeReadVersion, EBML.number(2)), ]))]); } buildBody() { if (this.min) { return new Blob([EBML.build(EBML.element(EBML.ID.Segment, [ this.getSegmentInfo(), this.getTracks(), ...this.getClusterArray() ]))]); } else { return new Blob([EBML.build(EBML.element(EBML.ID.Segment, [ this.getSeekHead(), this.getVoid(4100), this.getSegmentInfo(), this.getTracks(), this.getVoid(1100), ...this.getClusterArray() ]))]); } } getSeekHead() { return EBML.element(EBML.ID.SeekHead, [ EBML.element(EBML.ID.Seek, [ EBML.element(EBML.ID.SeekID, EBML.bytes(EBML.ID.Info)), EBML.element(EBML.ID.SeekPosition, EBML.number(4050)) ]), EBML.element(EBML.ID.Seek, [ EBML.element(EBML.ID.SeekID, EBML.bytes(EBML.ID.Tracks)), EBML.element(EBML.ID.SeekPosition, EBML.number(4200)) ]), ]); } getVoid(length = 2000) { return EBML.element(EBML.ID.Void, EBML.bytes(new Uint8Array(length))); } getSegmentInfo() { return EBML.element(EBML.ID.Info, [ EBML.element(EBML.ID.TimecodeScale, EBML.number(1000000)), EBML.element(EBML.ID.MuxingApp, EBML.string('flv.js + assparser_qli5 -> simple-ebml-builder')), EBML.element(EBML.ID.WritingApp, EBML.string('flvass2mkv.js by qli5')), EBML.element(EBML.ID.Duration, EBML.float(this.duration)), EBML.element(EBML.ID.SegmentUID, EBML.bytes(this.segmentUID)), ]); } getTracks() { return EBML.element(EBML.ID.Tracks, [ this.getVideoTrackEntry(), this.getAudioTrackEntry(), this.getSubtitleTrackEntry() ]); } getVideoTrackEntry() { return EBML.element(EBML.ID.TrackEntry, [ EBML.element(EBML.ID.TrackNumber, EBML.number(1)), EBML.element(EBML.ID.TrackUID, EBML.number(this.trackUIDBase + 1)), EBML.element(EBML.ID.TrackType, EBML.number(0x01)), EBML.element(EBML.ID.FlagLacing, EBML.number(0x00)), EBML.element(EBML.ID.CodecID, EBML.string(this.trackMetadata.h264.codecId)), EBML.element(EBML.ID.CodecPrivate, EBML.bytes(this.trackMetadata.h264.codecPrivate)), EBML.element(EBML.ID.DefaultDuration, EBML.number(this.trackMetadata.h264.defaultDuration)), EBML.element(EBML.ID.Language, EBML.string('und')), EBML.element(EBML.ID.Video, [ EBML.element(EBML.ID.PixelWidth, EBML.number(this.trackMetadata.h264.pixelWidth)), EBML.element(EBML.ID.PixelHeight, EBML.number(this.trackMetadata.h264.pixelHeight)), EBML.element(EBML.ID.DisplayWidth, EBML.number(this.trackMetadata.h264.displayWidth)), EBML.element(EBML.ID.DisplayHeight, EBML.number(this.trackMetadata.h264.displayHeight)), ]), ]); } getAudioTrackEntry() { return EBML.element(EBML.ID.TrackEntry, [ EBML.element(EBML.ID.TrackNumber, EBML.number(2)), EBML.element(EBML.ID.TrackUID, EBML.number(this.trackUIDBase + 2)), EBML.element(EBML.ID.TrackType, EBML.number(0x02)), EBML.element(EBML.ID.FlagLacing, EBML.number(0x00)), EBML.element(EBML.ID.CodecID, EBML.string(this.trackMetadata.aac.codecId)), EBML.element(EBML.ID.CodecPrivate, EBML.bytes(this.trackMetadata.aac.codecPrivate)), EBML.element(EBML.ID.DefaultDuration, EBML.number(this.trackMetadata.aac.defaultDuration)), EBML.element(EBML.ID.Language, EBML.string('und')), EBML.element(EBML.ID.Audio, [ EBML.element(EBML.ID.SamplingFrequency, EBML.float(this.trackMetadata.aac.samplingFrequence)), EBML.element(EBML.ID.Channels, EBML.number(this.trackMetadata.aac.channels)), ]), ]); } getSubtitleTrackEntry() { return EBML.element(EBML.ID.TrackEntry, [ EBML.element(EBML.ID.TrackNumber, EBML.number(3)), EBML.element(EBML.ID.TrackUID, EBML.number(this.trackUIDBase + 3)), EBML.element(EBML.ID.TrackType, EBML.number(0x11)), EBML.element(EBML.ID.FlagLacing, EBML.number(0x00)), EBML.element(EBML.ID.CodecID, EBML.string(this.trackMetadata.ass.codecId)), EBML.element(EBML.ID.CodecPrivate, EBML.bytes(this.trackMetadata.ass.codecPrivate)), EBML.element(EBML.ID.Language, EBML.string('und')), ]); } getClusterArray() { // H264 codecState this.blocks.h264[0].simple = false; this.blocks.h264[0].codecState = this.trackMetadata.h264.codecPrivate; let i = 0; let j = 0; let k = 0; let clusterTimeCode = 0; let clusterContent = [EBML.element(EBML.ID.Timecode, EBML.number(clusterTimeCode))]; let ret = [clusterContent]; const progressThrottler = Math.pow(2, Math.floor(Math.log(this.blocks.h264.length >> 7) / Math.log(2))) - 1; for (i = 0; i < this.blocks.h264.length; i++) { const e = this.blocks.h264[i]; for (; j < this.blocks.aac.length; j++) { if (this.blocks.aac[j].timestamp < e.timestamp) { clusterContent.push(this.getBlocks(this.blocks.aac[j], clusterTimeCode)); } else { break; } } for (; k < this.blocks.ass.length; k++) { if (this.blocks.ass[k].timestamp < e.timestamp) { clusterContent.push(this.getBlocks(this.blocks.ass[k], clusterTimeCode)); } else { break; } } if (e.isKeyframe/* || clusterContent.length > 72 */) { // start new cluster clusterTimeCode = e.timestamp; clusterContent = [EBML.element(EBML.ID.Timecode, EBML.number(clusterTimeCode))]; ret.push(clusterContent); } clusterContent.push(this.getBlocks(e, clusterTimeCode)); if (this.onprogress && !(i & progressThrottler)) this.onprogress({ loaded: i, total: this.blocks.h264.length }); } for (; j < this.blocks.aac.length; j++) clusterContent.push(this.getBlocks(this.blocks.aac[j], clusterTimeCode)); for (; k < this.blocks.ass.length; k++) clusterContent.push(this.getBlocks(this.blocks.ass[k], clusterTimeCode)); if (this.onprogress) this.onprogress({ loaded: i, total: this.blocks.h264.length }); if (ret[0].length == 1) ret.shift(); ret = ret.map(clusterContent => EBML.element(EBML.ID.Cluster, clusterContent)); return ret; } getBlocks(e, clusterTimeCode) { if (e.simple) { return EBML.element(EBML.ID.SimpleBlock, [ EBML.vintEncodedNumber(e.track), EBML.int16(e.timestamp - clusterTimeCode), EBML.bytes(e.isKeyframe ? [128] : [0]), EBML.bytes(e.frame) ]); } else { let blockGroupContent = [EBML.element(EBML.ID.Block, [ EBML.vintEncodedNumber(e.track), EBML.int16(e.timestamp - clusterTimeCode), EBML.bytes([0]), EBML.bytes(e.frame) ])]; if (typeof e.duration != 'undefined') { blockGroupContent.push(EBML.element(EBML.ID.BlockDuration, EBML.number(e.duration))); } if (typeof e.codecState != 'undefined') { blockGroupContent.push(EBML.element(EBML.ID.CodecState, EBML.bytes(e.codecState))); } return EBML.element(EBML.ID.BlockGroup, blockGroupContent); } } }; const FLVASS2MKV = class { constructor(config = {}) { this.onflvprogress = null; this.onassprogress = null; this.onurlrevokesafe = null; this.onfileload = null; this.onmkvprogress = null; this.onload = null; Object.assign(this, config); this.mkvConfig = { onprogress: this.onmkvprogress }; Object.assign(this.mkvConfig, config.mkvConfig); } /** * Demux FLV into H264 + AAC stream and ASS into line stream; then * remux them into a MKV file. * @param {Blob|string|ArrayBuffer} flv * @param {Blob|string|ArrayBuffer} ass */ async build(flv = './gen_case.flv', ass = './gen_case.ass') { // load flv and ass as arraybuffer await Promise.all([ new Promise((r, j) => { if (flv instanceof Blob) { const e = new FileReader(); e.onprogress = this.onflvprogress; e.onload = () => r(flv = e.result); e.onerror = j; e.readAsArrayBuffer(flv); } else if (typeof flv == 'string') { const e = new XMLHttpRequest(); e.responseType = 'arraybuffer'; e.onprogress = this.onflvprogress; e.onload = () => r(flv = e.response); e.onerror = j; e.open('get', flv); e.send(); flv = 2; // onurlrevokesafe } else if (flv instanceof ArrayBuffer) { r(flv); } else { j(new TypeError('flvass2mkv: flv {Blob|string|ArrayBuffer}')); } if (typeof ass != 'string' && this.onurlrevokesafe) this.onurlrevokesafe(); }), new Promise((r, j) => { if (ass instanceof Blob) { const e = new FileReader(); e.onprogress = this.onflvprogress; e.onload = () => r(ass = e.result); e.onerror = j; e.readAsArrayBuffer(ass); } else if (typeof ass == 'string') { const e = new XMLHttpRequest(); e.responseType = 'arraybuffer'; e.onprogress = this.onflvprogress; e.onload = () => r(ass = e.response); e.onerror = j; e.open('get', ass); e.send(); ass = 2; // onurlrevokesafe } else if (ass instanceof ArrayBuffer) { r(ass); } else { j(new TypeError('flvass2mkv: ass {Blob|string|ArrayBuffer}')); } if (typeof flv != 'string' && this.onurlrevokesafe) this.onurlrevokesafe(); }), ]); if (this.onfileload) this.onfileload(); const mkv = new MKV(this.mkvConfig); const assParser = new ASS(); ass = assParser.parseFile(ass); mkv.addASSMetadata(ass); mkv.addASSStream(ass); const flvProbeData = FLVDemuxer.probe(flv); const flvDemuxer = new FLVDemuxer(flvProbeData); let mediaInfo = null; let h264 = null; let aac = null; flvDemuxer.onDataAvailable = (...array) => { array.forEach(e => { if (e.type == 'video') h264 = e; else if (e.type == 'audio') aac = e; else throw new Error(\`MKVRemuxer: unrecoginzed data type \${e.type}\`); }); }; flvDemuxer.onMediaInfo = i => mediaInfo = i; flvDemuxer.onTrackMetadata = (i, e) => { if (i == 'video') mkv.addH264Metadata(e); else if (i == 'audio') mkv.addAACMetadata(e); else throw new Error(\`MKVRemuxer: unrecoginzed metadata type \${i}\`); }; flvDemuxer.onError = e => { throw new Error(e); }; const finalOffset = flvDemuxer.parseChunks(flv, flvProbeData.dataOffset); if (finalOffset != flv.byteLength) throw new Error('FLVDemuxer: unexpected EOF'); mkv.addH264Stream(h264); mkv.addAACStream(aac); const ret = mkv.build(); if (this.onload) this.onload(ret); return ret; } }; // if nodejs then test if (typeof require == 'function') { (async () => { const fs = require('fs'); const assFile = fs.readFileSync('gen_case.ass').buffer; const flvFile = fs.readFileSync('large_case.flv').buffer; fs.writeFileSync('out.mkv', await new FLVASS2MKV({ onmkvprogress: console.log.bind(console) }).build(flvFile, assFile)); })(); } </script> <script> const fileProgress = document.getElementById('fileProgress'); const mkvProgress = document.getElementById('mkvProgress'); const a = document.getElementById('a'); top.exec = async option => { const defaultOption = { onflvprogress: ({ loaded, total }) => { fileProgress.value = loaded; fileProgress.max = total; }, onfileload: () => { console.timeEnd('file'); console.time('flvass2mkv'); }, onmkvprogress: ({ loaded, total }) => { mkvProgress.value = loaded; mkvProgress.max = total; }, name: 'merged.mkv', }; option = Object.assign(defaultOption, option); a.download = a.textContent = option.name; console.time('file'); const mkv = await new FLVASS2MKV(option).build(option.flv, option.ass); console.timeEnd('flvass2mkv'); a.href = URL.createObjectURL(mkv); } </script> `); // 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 }) { 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.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': case 'hdflv2': case 'flv720': case 'flv480': //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); } if (shouldBe && shouldBe != res.format) { switch (shouldBe) { case 'flv': case 'hdflv2': case 'flv720': case 'flv480': this.flvs = null; break; case 'hdmp4': case 'mp4': this.mp4 = null; break; } throw `URL interface error: response is not ${shouldBe}`; } switch (res.format) { case 'flv': case 'hdflv2': case 'flv720': case 'flv480': return this.flvs = this.flvs.resolve(res.durl.map(e => e.url.replace('http:', this.protocol))); case 'hdmp4': case 'mp4': 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 (!(accept_quality instanceof Array)) accept_quality = Array.from(this.playerWin.document.querySelector('div.bilibili-player-video-btn-quality > div ul').getElementsByTagName('li')).map(e => e.getAttribute('data-value')); this.flvFormatName = accept_quality.includes('80') ? 'flv' : accept_quality.includes('64') ? 'flv720' : 'flv480'; this.mp4FormatName = 'mp4'; } 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/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 'flv480': if (accept_format.includes('flv720')) break; case 'flv720': if (accept_format.includes('flv')) break; case 'flv': case 'hdflv2': 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; } _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/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/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/playurl?') || a.url.includes('bangumi.bilibili.com/player/web_api/v2/playurl?')) { let _success = a.success; a.success = res => { if (self.proxy && res.format == 'flv') { self.resolveFormat(res, format); 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/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/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(); }); const { fetchDanmaku, generateASS, setPosition } = new ASSDownloader(); fetchDanmaku(this.cid, danmaku => { if (this.blocker) { if (this.playerWin.localStorage.bilibili_player_settings) { let regexps = JSON.parse(this.playerWin.localStorage.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': document.title, 'ori': 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.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.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/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 partialCache = await this.loadPartialFLVFromCache(index); let burl = this.flvs[index]; if (partialCache) burl += `&bstart=${partialCache.size}`; let opt = { fetch: this.playerWin.fetch, method: 'GET', mode: 'cors', cache: 'default', referrerPolicy: 'no-referrer-when-downgrade', cacheLoaded: partialCache ? partialCache.size : 0, headers: partialCache && (!burl.includes('wsTime')) ? { Range: `bytes=${partialCache.size}-` } : undefined }; opt.onprogress = progressHandler; opt.onerror = opt.onabort = ({ target, type }) => { let pBlob = target.getPartialBlob(); if (partialCache) pBlob = new Blob([partialCache, pBlob]); this.savePartialFLVToCache(index, pBlob); } let fch = new DetailedFetchBlob(burl, opt); this.flvsDetailedFetch[index] = fch; let fullResponse = await fch.getBlob(); this.flvsDetailedFetch[index] = undefined; if (partialCache) { fullResponse = new Blob([partialCache, fullResponse]); this.cleanPartialFLVInCache(index); } this.saveFLVToCache(index, fullResponse); return (this.flvsBlob[index] = fullResponse); })(); 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 formatToValue(format) { switch (format) { case 'hdflv2': return '112'; case 'flv': return '80'; case 'flv720': return '64'; case 'hdmp4': return '64'; // data-value is still '64' instead of '48'. return '48'; case 'flv480': return '32'; case 'mp4': return '16'; default: return null; } } static valueToFormat(value) { switch (parseInt(value)) { case 112: return 'hdflv2'; case 80: return 'flv'; case 64: return 'flv720'; case 48: return 'hdmp4'; case 32: return 'flv480'; case 16: return 'mp4'; 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.getElementsByClassName('v-title')[0] || top.document.getElementsByClassName('header-info')[0] || top.document.getElementsByClassName('video-info-module')[0]).children[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'); 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'); if (!ret) return null; ret = ret.src; ret = ret.slice(0, ret.indexOf('.jpg') + 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(); } markOPPosition() { let collectionId = this.getCollectionId(); if (!(this.userdata.oped[collectionId] instanceof Array)) this.userdata.oped[collectionId] = []; this.userdata.oped[collectionId][0] = this.video.currentTime; } markEDPostion() { let collectionId = this.getCollectionId(); if (!(this.userdata.oped[collectionId] instanceof Array)) this.userdata.oped[collectionId] = []; this.userdata.oped[collectionId][1] = (this.video.currentTime); } clearOPEDPosition() { let collectionId = this.getCollectionId(); this.userdata.oped[collectionId] = undefined; } skipOPED() { let collectionId = this.getCollectionId(); if (!(this.userdata.oped[collectionId] instanceof Array)) return; if (this.userdata.oped[collectionId][0]) { if (this.video.currentTime < this.userdata.oped[collectionId][0]) { this.video.currentTime = this.userdata.oped[collectionId][0]; this.hintInfo('BiliPolyfill: 已跳过片头'); } } if (this.userdata.oped[collectionId][1]) { let edHandler = v => { if (v.target.currentTime > this.userdata.oped[collectionId][1]) { v.target.removeEventListener('timeupdate', edHandler); v.target.dispatchEvent(new Event('ended')); } } this.video.addEventListener('timeupdate', edHandler); } } 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> = ' + 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/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/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) { let h = document.querySelector('div.viewbox div.info') || document.querySelector('div.bangumi-header div.header-info') || document.querySelector('div.video-info-module'); let 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; await monkey.queryInfo('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; mp4A.href = await monkey.queryInfo('mp4'); 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'; }; 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'; h.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 = `<a href="${flvs[i]}">FLV分段 ${i + 1}</a>`; tr.insertCell(1).innerHTML = '<a>缓存本段</a>'; tr.insertCell(2).innerHTML = '<progress value="0" max="100">进度条</progress>'; 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 = '<a>全部复制到剪贴板</a>'; tr.insertCell(1).innerHTML = '<a>缓存全部+自动合并</a>'; tr.insertCell(2).innerHTML = `<progress value="0" max="${flvs.length + 1}">进度条</progress>`; if (top.location.href.includes('bangumi')) { tr.children[0].children[0].onclick = () => UI.copyToClipboard(flvs.join('\n')); } else { tr.children[0].innerHTML = '<a download="biliTwin.ef2">IDM导出</a>'; 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 = '<td colspan="3">合并功能推荐配置:至少8G RAM。把自己下载的分段FLV拖动到这里,也可以合并哦~</td>'; table.insertRow(-1).innerHTML = cache ? '<td colspan="3">下载的缓存分段会暂时停留在电脑里,过一段时间会自动消失。建议只开一个标签页。</td>' : '<td colspan="3">建议只开一个标签页。关掉标签页后,缓存就会被清理。别忘了另存为!</td>'; 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 = `<td colspan="3">${file.name}</td>`; } 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 = `<td colspan="3"><a href="${url}" download="${outputName}">${outputName}</a></td>`; } 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 = '<td colspan="3">已屏蔽网页播放器的网络链接。切换清晰度可重新激活播放器。</td>'; 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.getElementsByClassName('v-title')[0] || top.document.getElementsByClassName('header-info')[0] || top.document.getElementsByClassName('video-info-module')[0]).children[0].textContent.trim(); bar.value++; table.insertRow(0).innerHTML = ` <td colspan="3" style="border: 1px solid black"> <a href="${url}" download="${outputName}.flv">保存合并后FLV</a> <a href="${ass}" download="${outputName}.ass">弹幕ASS</a> <a>打包MKV(软字幕封装)</a> 记得清理分段缓存哦~ </td> `; 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 = `<td colspan="3">这个浏览器不支持缓存呢~关掉标签页后,缓存马上就会消失哦</td>`); temporaryStorage.queryUsageAndQuota((usage, quota) => resolve(tr.innerHTML = `<td colspan="3">缓存已用空间:${Math.round(usage / 1048576)}MB / ${Math.round(quota / 1048576)}MB 也包括了B站本来的缓存</td>`) ); }); } // 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 = ` <a class="context-menu-a"> BiliMonkey <span class="bpui-icon bpui-icon-arrow-down" style="transform:rotate(-90deg);margin-top:3px;"></span> </a> <ul> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 下载FLV </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 下载MP4 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 下载ASS </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 设置/帮助/关于 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> (测)载入缓存FLV </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> (测)强制刷新 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> (测)重启脚本 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> (测)销毁播放器 </a> </li> </ul> `; 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 () => { 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[5].onclick = () => { top.location.reload(true); }; ul.children[6].onclick = () => { playerWin.dispatchEvent(new Event('unload')); }; ul.children[7].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 = ` <a class="context-menu-a"> BiliPolyfill <span class="bpui-icon bpui-icon-arrow-down" style="transform:rotate(-90deg);margin-top:3px;"></span> </a> <ul> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 获取封面 </a> </li> <li class="context-menu-menu"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 更多播放速度 <span class="bpui-icon bpui-icon-arrow-down" style="transform:rotate(-90deg);margin-top:3px;"></span> </a> <ul> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 0.1 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 3 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 点击确认 <input type="text" style="width: 35px; height: 70%"> </a> </li> </ul> </li> <li class="context-menu-menu"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 片头片尾 <span class="bpui-icon bpui-icon-arrow-down" style="transform:rotate(-90deg);margin-top:3px;"></span> </a> <ul> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 标记片头:<span></span> </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 标记片尾:<span></span> </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 取消标记 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 检视数据 </a> </li> </ul> </li> <li class="context-menu-menu"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 找上下集 <span class="bpui-icon bpui-icon-arrow-down" style="transform:rotate(-90deg);margin-top:3px;"></span> </a> <ul> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> <span></span> </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> <span></span> </a> </li> </ul> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 小窗播放 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> 设置/帮助/关于 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> (测)立即保存数据 </a> </li> <li class="context-menu-function"> <a class="context-menu-a"> <span class="video-contextmenu-icon"></span> (测)强制清空数据 </a> </li> </ul> `; 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.markOPPosition(); }; ul.children[2].children[1].children[1].onclick = () => { polyfill.markEDPostion(3); }; ul.children[2].children[1].children[2].onclick = () => { polyfill.clearOPEDPosition(); }; ul.children[2].children[1].children[3].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[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 = '<td>设置自动保存,刷新后生效。</td>'; table.insertRow(-1).innerHTML = '<td>视频下载组件的缓存功能只在Windows+Chrome测试过,如果出现问题,请关闭缓存。</td>'; table.insertRow(-1).innerHTML = '<td>功能增强组件尽量保证了兼容性。但如果有同功能脚本/插件,请关闭本插件的对应功能。</td>'; table.insertRow(-1).innerHTML = '<td>这个脚本乃“按原样”提供,不附带任何明示,暗示或法定的保证,包括但不限于其没有缺陷,适合特定目的或非侵权。</td>'; table.insertRow(-1).innerHTML = '<td><a href="https://gf.qytechs.cn/zh-CN/scripts/27819" target="_blank">更新/讨论</a> <a href="https://github.com/liqi0816/bilitwin/" target="_blank">GitHub</a> Author: qli5. Copyright: qli5, 2014+, 田生, grepmusic</td>'; 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', '弹幕过滤:在网页播放器里设置的屏蔽词也对下载的弹幕生效。'], ]; let table = document.createElement('table'); table.style.width = '100%'; table.style.lineHeight = '2em'; table.insertRow(-1).innerHTML = '<td style="text-align:center">BiliMonkey(视频抓取组件)</td>'; table.insertRow(-1).innerHTML = '<td style="text-align:center">因为作者偷懒了,缓存的三个选项最好要么全开,要么全关。最好。</td>'; 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); td.appendChild(checkbox); td.appendChild(document.createTextNode(d[1])); } return table; } static genPolyfillOptionTable(option = {}) { const description = [ ['betabeta', '增强组件总开关 <---------更加懒得测试了,反正以后B站也会自己提供这些功能。也许吧。'], //betabeta ['badgeWatchLater', '稍后再看添加数字角标'], ['dblclick', '双击全屏'], ['scroll', '自动滚动到播放器'], ['recommend', '弹幕列表换成相关视频'], ['electric', '整合充电榜与换P倒计时'], //['electricSkippable', '跳过充电榜'], ['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 = '<td style="text-align:center">BiliPolyfill(功能增强组件)</td>'; table.insertRow(-1).innerHTML = '<td style="text-align:center">懒鬼作者还在测试的时候,B站已经上线了原生的稍后再看(๑•̀ㅂ•́)و✧</td>'; 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); td.appendChild(checkbox); td.appendChild(document.createTextNode(d[1])); } 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号或番剧号:[片头,片尾]。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'; 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 = ` <div class="bilibili-player-video-toast-item"> <div class="bilibili-player-video-toast-item-text"> <span>${text}</span> </div> </div> `; 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, 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.location.href.includes('www.bilibili.com/video/av')) 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();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址