From 73c64184e02ee88bae3a1c7aa01a503a0c393d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=94=9F?= Date: Mon, 19 Feb 2018 16:41:30 +0800 Subject: [PATCH] initial commit --- .eslintrc.js | 89 +++++++++ README.md | 13 ++ extension/_locales/en/messages.json | 66 +++++++ extension/_locales/zh_CN/messages.json | 53 ++++++ extension/_locales/zh_TW/messages.json | 50 +++++ extension/background/acfun.js | 40 ++++ extension/background/bilibili.js | 34 ++++ extension/background/main.js | 186 +++++++++++++++++++ extension/danmaku/ass.js | 123 +++++++++++++ extension/danmaku/layout.js | 242 +++++++++++++++++++++++++ extension/danmaku/parser.js | 96 ++++++++++ extension/download/danmaku.ass | 0 extension/download/download.js | 67 +++++++ extension/font/font.js | 75 ++++++++ extension/icon/danmaku.svg | 7 + extension/manifest.json | 58 ++++++ extension/options/ext_options.js | 143 +++++++++++++++ extension/options/options.css | 22 +++ extension/options/options.html | 77 ++++++++ extension/options/options.js | 21 +++ extension/popup/popup.css | 12 ++ extension/popup/popup.html | 17 ++ extension/popup/popup.js | 52 ++++++ 23 files changed, 1543 insertions(+) create mode 100644 .eslintrc.js create mode 100644 README.md create mode 100644 extension/_locales/en/messages.json create mode 100644 extension/_locales/zh_CN/messages.json create mode 100644 extension/_locales/zh_TW/messages.json create mode 100644 extension/background/acfun.js create mode 100644 extension/background/bilibili.js create mode 100644 extension/background/main.js create mode 100644 extension/danmaku/ass.js create mode 100644 extension/danmaku/layout.js create mode 100644 extension/danmaku/parser.js create mode 100644 extension/download/danmaku.ass create mode 100644 extension/download/download.js create mode 100644 extension/font/font.js create mode 100644 extension/icon/danmaku.svg create mode 100644 extension/manifest.json create mode 100644 extension/options/ext_options.js create mode 100644 extension/options/options.css create mode 100644 extension/options/options.html create mode 100644 extension/options/options.js create mode 100644 extension/popup/popup.css create mode 100644 extension/popup/popup.html create mode 100644 extension/popup/popup.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..8322353 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,89 @@ +module.exports = { + root: true, + extends: 'eslint:recommended', + parserOptions: { + ecmaVersion: 8, + ecmaFeatures: { + impliedStrict: true, + }, + }, + env: { + es6: true, + browser: true, + worker: true, + webextensions: true, + }, + "rules": { + 'linebreak-style': ['warn', 'unix'], + 'unicode-bom': ['warn', 'never'], + 'no-trailing-spaces': ['warn'], + 'no-multi-spaces': ['warn'], + 'no-tabs': ['warn'], + 'eol-last': ['warn'], + 'indent': ['warn', 2, { flatTernaryExpressions: true }], + 'semi': ['warn', 'always'], + 'no-extra-semi': ['off'], + 'comma-dangle': ['warn', 'always-multiline'], + + 'no-multiple-empty-lines': ['warn', { max: 2 }], + 'quotes': ['warn', 'single', { avoidEscape: true, allowTemplateLiterals: true }], + 'arrow-parens': ['warn', 'as-needed'], + 'quote-props': ['warn', 'as-needed', { numbers: true }], + 'dot-notation': ['warn'], + + 'arrow-spacing': ['warn'], + 'block-spacing': ['warn'], + 'comma-spacing': ['warn'], + 'semi-spacing': ['warn'], + 'computed-property-spacing': ['warn'], + 'func-call-spacing': ['warn'], + 'key-spacing': ['warn'], + 'switch-colon-spacing': ['warn'], + 'keyword-spacing': ['warn'], + 'object-curly-spacing': ['warn', 'always'], + 'array-bracket-spacing': ['warn', 'never'], + 'template-tag-spacing': ['warn'], + 'space-unary-ops': ['warn'], + 'rest-spread-spacing': ['warn'], + 'space-infix-ops': ['warn'], + 'operator-linebreak': ['warn', 'after'], + 'implicit-arrow-linebreak': ['warn'], + 'no-whitespace-before-property': ['warn'], + 'space-before-function-paren': ['warn', {anonymous: 'always', named: 'never', asyncArrow: 'always'}], + 'space-in-parens': ['warn'], + 'spaced-comment': ['warn', 'always', { + line: { markers: ['/'] }, + block: { markers: ['!'], exceptions: ['*'] } + }], + 'brace-style': ['warn', '1tbs', { allowSingleLine: true }], + 'func-style': ['warn', 'expression', { allowArrowFunctions: true }], + 'id-length': ['warn', { min: 0, max: 40, properties: 'never' }], + + 'eqeqeq': ['warn', 'always', {'null': 'ignore'}], + 'wrap-iife': ['warn', 'outside'], + 'yoda': ['warn', 'never', { onlyEquality: true }], + 'no-var': ['warn'], + 'no-constant-condition': ['warn', { checkLoops: false }], + 'no-empty': ['warn'], + + 'no-undefined': ['warn'], + 'no-shadow-restricted-names': ['warn'], + 'no-throw-literal': ['warn'], + 'no-eval': ['error'], + 'no-implied-eval': ['error'], + 'no-unused-vars': ['off'], + 'no-implicit-globals': ['error'], + 'no-new-wrappers': ['warn'], + 'no-proto': ['warn'], + 'operator-assignment': ['warn', 'always'], + 'no-control-regex': ['off'], + + 'no-extra-label': ['warn'], + + 'consistent-return': ['warn'], + + 'radix': ['warn'], + + 'complexity': ['warn'], + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2c5b79 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +## ASS Danmaku + +Download danmaku (video comments) in ASS format from AcFun & bilibili. Watch video on supported site, and danmaku download button will appear on the right of address bar. + +### Privacy + +This extension do not collect any information from your computer. + +This extension do not send or modify any network connection. Your danmaku file is converted offline. + +### License + +All source code is licensed under the Mozilla Public License. diff --git a/extension/_locales/en/messages.json b/extension/_locales/en/messages.json new file mode 100644 index 0000000..fb76bcb --- /dev/null +++ b/extension/_locales/en/messages.json @@ -0,0 +1,66 @@ +{ + "extensionName": { + "message": "ASS Danmaku", + "description": "Name of the extension." + }, + "extensionDescription": { + "message": "Download danmaku (video comments) in ASS format from AcFun & bilibili. Watch video on supported site, and danmaku download button will appear on the right of address bar.", + "description": "Description of the extension." + }, + "extensionButtonTitle": { + "message": "Download Danmaku", + "description": "Title of the extension button." + }, + "optionResolutionTitle": { + "message": "Screen Resolution", + "description": "Screen Resolution affect the size of canvas for display subtitle. Lower resolution means larger font size." + }, + "optionBottomReserved": { + "message": "Bottom Reserved Height", + "description": "Reserved space at the bottom of the screen so that danmaku will not overlap subtitles." + }, + "optionFontFamilyTitle": { + "message": "Font Family", + "description": "User may chose preferred font family. Danmaku will be styled using such font family, and the width of danmaku were calculated based on the given font family. Please note that your video player may override this option." + }, + "optionFontSizeTitle": { + "message": "Zoom Level", + "description": "The font size of danmaku is set by a default value on each site. Here we only provide a general zoom option." + }, + "optionDanmakuSpace": { + "message": "Space between danmaku", + "description": "Keep some more space between each danmaku." + }, + "optionNormalDuration": { + "message": "Duration (Normal)", + "description": "How long a danmaku will be displayed on screen. (for danmaku moving from right to left)" + }, + "optionFixedDuration": { + "message": "Duration (Fixed)", + "description": "How long a danmaku will be displayed on screen. (for danmaku sticked on top or bottom)" + }, + "optionTolerantDelay": { + "message": "Tolerant Delay", + "description": "Danmaku may be delayed for better layout. This option describe the maximum delay allowed." + }, + "optionTextOpacity": { + "message": "Opacity", + "description": "Danmaku may be semitransparent." + }, + "optionUnitSecond": { + "message": "s", + "description": "The unit for measuring time." + }, + "optionUnitPixel": { + "message": "px", + "description": "The unit for measuring subtitle canvas and font size." + }, + "assOriginal": { + "message": "Generated by ASS Danmaku based on $1", + "description": "The description inserted into ASS file header to describe the source of current file." + }, + "downloadingTip": { + "message": "[Converting]", + "description": "The texts in popup menu indicating downloading status." + } +} diff --git a/extension/_locales/zh_CN/messages.json b/extension/_locales/zh_CN/messages.json new file mode 100644 index 0000000..55d3304 --- /dev/null +++ b/extension/_locales/zh_CN/messages.json @@ -0,0 +1,53 @@ +{ + "extensionName": { + "message": "ASS Danmaku" + }, + "extensionDescription": { + "message": "从 AcFun & bilibili 以 ASS 格式下载弹幕。在支持的网站观看视频,下载按钮将会出现在地址栏右侧。" + }, + "extensionButtonTitle": { + "message": "下载弹幕" + }, + "popupNoDanmakuFound": { + "message": "在支持的站点上观看视频,对应的弹幕下载会出现在这里" + }, + "optionResolutionTitle": { + "message": "画布分辨率" + }, + "optionBottomReserved": { + "message": "字幕区高度" + }, + "optionFontFamilyTitle": { + "message": "字体" + }, + "optionFontSizeTitle": { + "message": "字号比例" + }, + "optionDanmakuSpace": { + "message": "弹幕间距" + }, + "optionNormalDuration": { + "message": "滑动弹幕时长" + }, + "optionFixedDuration": { + "message": "固定弹幕时长" + }, + "optionTolerantDelay": { + "message": "弹幕最大延迟" + }, + "optionTextOpacity": { + "message": "不透明度" + }, + "optionUnitSecond": { + "message": "秒" + }, + "optionUnitPixel": { + "message": "像素" + }, + "assOriginal": { + "message": "Generated by ASS Danmaku based on $1" + }, + "downloadingTip": { + "message": "【正在转换】" + } +} diff --git a/extension/_locales/zh_TW/messages.json b/extension/_locales/zh_TW/messages.json new file mode 100644 index 0000000..3e488e4 --- /dev/null +++ b/extension/_locales/zh_TW/messages.json @@ -0,0 +1,50 @@ +{ + "extensionName": { + "message": "ASS Danmaku" + }, + "extensionDescription": { + "message": "從 AcFun 和 bilibili 以 ASS 格式下載彈幕。在支援的站點觀看視頻,下載按鈕將出現在導覽列右側。" + }, + "extensionButtonTitle": { + "message": "下載彈幕" + }, + "optionResolutionTitle": { + "message": "畫布解析度" + }, + "optionBottomReserved": { + "message": "字母區域高度" + }, + "optionFontFamilyTitle": { + "message": "字型" + }, + "optionFontSizeTitle": { + "message": "字型大小比例" + }, + "optionDanmakuSpace": { + "message": "彈幕間距" + }, + "optionNormalDuration": { + "message": "滑動彈幕期間" + }, + "optionFixedDuration": { + "message": "固定彈幕期間" + }, + "optionTolerantDelay": { + "message": "彈幕最大延遲" + }, + "optionTextOpacity": { + "message": "不透明度" + }, + "optionUnitSecond": { + "message": "秒" + }, + "optionUnitPixel": { + "message": "像素" + }, + "assOriginal": { + "message": "Generated by ASS Danmaku based on $1" + }, + "downloadingTip": { + "message": "【轉換中】" + } +} diff --git a/extension/background/acfun.js b/extension/background/acfun.js new file mode 100644 index 0000000..a53f3ea --- /dev/null +++ b/extension/background/acfun.js @@ -0,0 +1,40 @@ +; (function () { + + window.onRequest(['http://www.acfun.cn/v/ac*'], function (response, pageContext) { + const html = new TextDecoder('utf-8').decode(response); + const pageDom = (new DOMParser()).parseFromString(html, 'text/html'); + const scriptTags = Array.from(pageDom.querySelectorAll('script')); + const script = scriptTags.find(script => ( + /^var pageInfo = \{.*}$/.test(script.textContent) + )); + const data = JSON.parse(script.textContent.match(/({.*})/)[1]); + const { title } = data; + const vidTitle = pageContext.metaInfo.vidTitle = pageContext.metaInfo.vidTitle || new Map(); + data.videoList.forEach(({ id, title: part }) => { + vidTitle.set(id, title + (part ? ' - ' + part : '')); + }); + }); + + window.onRequest(['http://danmu.aixifan.com/V4/*_*/*/*'], async function (response, pageContext, { url }) { + const vid = +(url.match(/http:\/\/danmu\.aixifan\.com\/V4\/(\d+)_/) || [])[1]; + const { danmaku } = window.danmaku.parser.acfun(response); + if (danmaku.length === 0) return; + const danmakuList = pageContext.danmakuList = pageContext.danmakuList || []; + const danmakuItem = danmakuList.find(({ id }) => id === `acfun-${vid}`); + if (danmakuItem) { + const danmakuMap = new Map(); + danmakuItem.content.concat(danmaku).forEach(danmaku => danmakuMap.set(danmaku.uuid, danmaku)); + danmakuItem.content = [...danmakuMap.values()]; + } else { + const vidTitle = pageContext.metaInfo.vidTitle; + const title = vidTitle && vidTitle.get(vid); + const name = 'A' + vid + (title ? ' - ' + title : ''); + danmakuList.push({ + id: `acfun-${vid}`, + meta: { name, url }, + content: danmaku, + }); + } + }); + +}()); diff --git a/extension/background/bilibili.js b/extension/background/bilibili.js new file mode 100644 index 0000000..133ddd4 --- /dev/null +++ b/extension/background/bilibili.js @@ -0,0 +1,34 @@ +; (function () { + + const getPageTitle = async tabId => (await browser.tabs.get(tabId)).title; + + window.onRequest(['https://api.bilibili.com/x/player/pagelist?*'], function (response, pageContext) { + const { data } = JSON.parse(new TextDecoder('utf-8').decode(response)); + const { tabId } = pageContext; + const cidTitle = pageContext.metaInfo.cidTitle = pageContext.metaInfo.cidTitle || new Map(); + data.forEach(({ cid, part }) => { + cidTitle.set(cid, (async () => { + const title = await getPageTitle(tabId); + const aidTitle = title.replace(/_.*$/, ''); + const partTitle = part ? ' - ' + part : ''; + return aidTitle + partTitle; + })()); + }); + }); + + window.onRequest(['https://comment.bilibili.com/*.xml'], async function (response, pageContext, { url }) { + const { cid, danmaku } = window.danmaku.parser.bilibili(response); + if (danmaku.length === 0) return; + const { tabId } = pageContext; + const cidTitle = pageContext.metaInfo.cidTitle; + const title = await (cidTitle && cidTitle.get(cid) || getPageTitle(tabId)); + const name = 'B' + cid + (title ? ' - ' + title : ''); + const danmakuList = pageContext.danmakuList = pageContext.danmakuList || []; + danmakuList.push({ + id: `bilibili-${cid}`, + meta: { name, url }, + content: danmaku, + }); + }); + +}()); diff --git a/extension/background/main.js b/extension/background/main.js new file mode 100644 index 0000000..9083e9a --- /dev/null +++ b/extension/background/main.js @@ -0,0 +1,186 @@ +; (function () { + + /** + * @typedef TabId + * @typedef {{ id: string, meta: Object, content: Object }} DanmakuInfo + * @typedef {{ tabId: TabId, danmakuList: ?Array., metaInfo: Object }} PageContent + */ + + /** @type {Map} */ + const context = new Map(); + /** @type {Map} */ + const exported = new Map(); + /** + * Export some function via message post to popup pages + * @param {Function} f + * @return {Function} + */ + const messageExport = f => { + exported.set(f.name, f); + return (...args) => Promise.resolve(f(...args)); + }; + + const pageContext = tabId => { + if (!context.has(tabId)) { + context.set(tabId, { + tabId, + danmakuList: [], + metaInfo: {}, + }); + } + const pageContext = context.get(tabId); + return pageContext; + }; + + /** + * @callback onRequestCallback + * @param {ArrayBuffer} response + * @param {PageContent} pageContent + */ + + /** + * + * @param {Array.} match + * @param {Function.} callback + */ + const onRequest = function (match, callback) { + browser.webRequest.onBeforeRequest.addListener(details => { + const { requestId, tabId, url } = details; + const filter = browser.webRequest.filterResponseData(requestId); + let capacity = 1 << 24; // 16MiB, this should be enough for our use case + let size = 0; + let buffer = new ArrayBuffer(capacity); + + filter.ondata = event => { + const { data } = event; + filter.write(data); + if (!buffer) return; + const length = data.byteLength; + if (size + length > capacity) { + buffer = null; + return; + } + const view = new Uint8Array(buffer, size, length); + view.set(new Uint8Array(data)); + size += length; + }; + + filter.onstop = event => { + filter.disconnect(); + if (!buffer) return; + const response = buffer.slice(0, size); + buffer = null; + (async () => { + const context = pageContext(tabId); + await callback(response, pageContext(tabId), { url }); + if (context.danmakuList.length) browser.pageAction.show(tabId); + })(); + }; + return {}; + }, { urls: match }, ['blocking']); + }; + + const revokePageAction = tabId => { + context.delete(tabId); + browser.pageAction.hide(tabId); + }; + + const clearPageDanmaku = tabId => { + const context = pageContext(tabId); + context.danmakuList.length = 0; + browser.pageAction.hide(tabId); + }; + + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.discarded) { + revokePageAction(tabId); + } else if (changeInfo.url) { + clearPageDanmaku(tabId); + } + }); + browser.tabs.onRemoved.addListener(tabId => { + revokePageAction(tabId); + }); + + const getDanmakuDetail = function (tabId, danmakuId) { + const pageContext = context.get(tabId); + if (!pageContext) return null; + const list = pageContext.danmakuList || []; + const danmaku = list.find(({ id }) => id === danmakuId); + return danmaku; + }; + + const random = () => `${Math.random()}`.slice(2); + const randomStuff = `danmaku-${Date.now()}-${random()}`; + const downloadDanmakuBaseUrl = browser.extension.getURL('download/danmaku.ass'); // `https://${randomStuff}.ass-danmaku.invalid.example.com/download/danmaku.ass`; + const listDanmaku = messageExport(function listDanmaku(tabId) { + const pageContext = context.get(tabId); + if (!pageContext) return []; + const list = pageContext.danmakuList || []; + return list.map(({ id, meta }) => ({ + id, + meta, + })); + }); + + const downloadDanmaku = messageExport(async function downloadDanmaku(tabId, danmakuId) { + const danmaku = getDanmakuDetail(tabId, danmakuId); + const [options] = await Promise.all([ + window.options.get(), + ]); + danmaku.layout = await window.danmaku.layout(danmaku.content, options); + const content = window.danmaku.ass(danmaku, options); + const url = window.download.url(content); + const filename = window.download.filename(danmaku.meta.name, 'ass'); + browser.downloads.download({ filename, url }); + }); + + browser.runtime.onMessage.addListener(async (request, sender, sendResponse) => { + const { method, params = [] } = request; + const handler = exported.get(method); + const response = await handler(...params); + return response; + }); + + window.onRequest = onRequest; + + // browser.webRequest.onBeforeRequest.addListener(async details => { + // const { requestId, url } = details; + // const params = new URLSearchParams(new URL(url).search); + // const tabId = +params.get('tabId'); + // const danmakuId = params.get('danmakuId'); + // const danmaku = getDanmakuDetail(tabId, danmakuId); + + // const [options] = await Promise.all([ + // window.options.get(), + // ]); + + // danmaku.layout = await window.danmaku.layout(danmaku.content, options); + // const content = window.danmaku.ass(danmaku, options); + // // const objectUrl = await window.download.url(content); + // const encoder = new TextEncoder(); + // // Add a BOM to make some ass parser library happier + // const bom = '\ufeff'; + // const encoded = encoder.encode(bom + content); + // const blob = new Blob([encoded], { type: 'application/octet-stream' }); + // const objectUrl = URL.createObjectURL(blob); + + // return { redirectUrl: objectUrl }; + // }, { urls: [downloadDanmakuBaseUrl + '?*'] }, ['blocking']); + + +}()); + + +// cancel function returns an object +// which contains a property `cancel` set to `true` + + +// // add the listener, +// // passing the filter argument and "blocking" +// browser.webRequest.onBeforeRequest.addListener( +// function (requestDetails) { +// console.log(requestDetails); +// return {}; +// }, { urls: ['*://*/*'] }, ['blocking'] +// ); diff --git a/extension/danmaku/ass.js b/extension/danmaku/ass.js new file mode 100644 index 0000000..f8223c6 --- /dev/null +++ b/extension/danmaku/ass.js @@ -0,0 +1,123 @@ +; (function () { + + const ass = (function () { + + // escape string for ass + const textEscape = s => ( + // VSFilter do not support escaped "{" or "}"; we use full-width version instead + s.replace(/{/g, '{').replace(/}/g, '}').replace(/\s/g, ' ') + ); + + const formatColorChannel = v => (v & 255).toString(16).toUpperCase().padStart(2, '0'); + + // format color + const formatColor = color => '&H' + ( + [color.b, color.g, color.r].map(formatColorChannel).join('') + ); + + // format timestamp + const formatTimestamp = time => { + const value = Math.round(time * 100) * 10; + const rem = value % 3600000; + const hour = (value - rem) / 3600000; + const fHour = hour.toFixed(0).padStart(2, '0'); + const fRem = new Date(rem).toISOString().slice(-11, -2); + return fHour + fRem; + }; + + // test is default color + const isDefaultColor = ({ r, g, b }) => r === 255 && g === 255 && b === 255; + // test is dark color + const isDarkColor = ({ r, g, b }) => r * 0.299 + g * 0.587 + b * 0.114 < 0x30; + + // Ass header + const header = info => [ + '[Script Info]', + `Title: ${info.title}`, + `Original Script: ${info.original}`, + 'ScriptType: v4.00+', + 'Collisions: Normal', + `PlayResX: ${info.playResX}`, + `PlayResY: ${info.playResY}`, + 'Timer: 10.0000', + '', + '[V4+ Styles]', + 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding', + `Style: Fix,${info.fontFamily},${info.fontSize},&H${info.alpha}FFFFFF,&H${info.alpha}FFFFFF,&H${info.alpha}000000,&H${info.alpha}000000,1,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0`, + `Style: Rtl,${info.fontFamily},${info.fontSize},&H${info.alpha}FFFFFF,&H${info.alpha}FFFFFF,&H${info.alpha}000000,&H${info.alpha}000000,1,0,0,0,100,100,0,0,1,2,0,2,20,20,2,0`, + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + ]; + + // Set color of text + const lineColor = ({ color }) => { + let output = []; + if (!isDefaultColor(color)) output.push(`\\c${formatColor(color)}`); + if (isDarkColor(color)) output.push(`\\3c&HFFFFFF`); + return output.join(''); + }; + + // Set fontsize + let defaultFontSize; + const lineFontSize = ({ size }) => { + if (size === defaultFontSize) return ''; + return `\\fs${size}`; + }; + const getCommonFontSize = list => { + const count = new Map(); + let commonCount = 0, common = 1; + list.forEach(({ size }) => { + let value = 1; + if (count.has(size)) value = count.get(size) + 1; + count.set(size, value); + if (value > commonCount) { + commonCount = value; + common = size; + } + }); + defaultFontSize = common; + return common; + }; + + // Add animation of danmaku + const lineMove = ({ layout: { type, start = null, end = null } }) => { + if (type === 'Rtl' && start && end) return `\\move(${start.x},${start.y},${end.x},${end.y})`; + if (type === 'Fix' && start) return `\\pos(${start.x},${start.y})`; + return ''; + }; + + // format one line + const formatLine = line => { + const start = formatTimestamp(line.layout.start.time); + const end = formatTimestamp(line.layout.end.time); + const type = line.layout.type; + const color = lineColor(line); + const fontSize = lineFontSize(line); + const move = lineMove(line); + const format = `${color}${fontSize}${move}`; + const text = textEscape(line.text); + return `Dialogue: 0,${start},${end},${type},,20,20,2,,{${format}}${text}`; + }; + + return (danmaku, options) => { + const info = { + title: danmaku.meta.name, + original: browser.i18n.getMessage('assOriginal', danmaku.meta.url), + playResX: options.resolutionX, + playResY: options.resolutionY, + fontFamily: options.fontFamily, + fontSize: getCommonFontSize(danmaku.layout), + alpha: formatColorChannel(0xFF * (100 - options.textOpacity) / 100), + }; + return [ + ...header(info), + ...danmaku.layout.map(formatLine).filter(x => x), + ].join('\r\n'); + }; + }()); + + window.danmaku = window.danmaku || {}; + window.danmaku.ass = ass; + +}()); diff --git a/extension/danmaku/layout.js b/extension/danmaku/layout.js new file mode 100644 index 0000000..f1ab9e7 --- /dev/null +++ b/extension/danmaku/layout.js @@ -0,0 +1,242 @@ +; (function () { + + const layout = (function () { + + const rtlCanvas = function (options) { + const { + resolutionX: wc, // width of canvas + resolutionY: hc, // height of canvas + bottomReserved: b, // reserved bottom height for subtitle + rtlDuration: u, // duration appeared on screen + maxDelay: maxr, // max allowed delay + } = options; + + // Initial canvas border + let used = [ + // p: top + // m: bottom + // tf: time completely enter screen + // td: time completely leave screen + // b: allow conflict with subtitle + // add a fake danmaku for describe top of screen + { p: -Infinity, m: 0, tf: Infinity, td: Infinity, b: false }, + // add a fake danmaku for describe bottom of screen + { p: hc, m: Infinity, tf: Infinity, td: Infinity, b: false }, + // add a fake danmaku for placeholder of subtitle + { p: hc - b, m: hc, tf: Infinity, td: Infinity, b: true }, + ]; + // Find out some position is available + const available = (hv, t0s, t0l, b) => { + const suggestion = []; + // Upper edge of candidate position should always be bottom of other danmaku (or top of screen) + used.forEach(i => { + if (i.m + hv >= hc) return; + const p = i.m; + const m = p + hv; + let tas = t0s; + let tal = t0l; + // and left border should be right edge of others + used.forEach(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); + }); + const r = Math.max(tas - t0s, tal - t0l); + if (r > maxr) return; + // save a candidate position + suggestion.push({ p, r }); + }); + // sorted by its vertical position + suggestion.sort((x, y) => x.p - y.p); + let mr = maxr; + // the bottom and later choice should be ignored + const filtered = suggestion.filter(i => { + if (i.r >= mr) return false; + mr = i.r; + return true; + }); + return filtered; + }; + // mark some area as used + let use = (p, m, tf, td) => { + used.push({ p, m, tf, td, b: false }); + }; + // remove danmaku not needed anymore by its time + const syn = (t0s, t0l) => { + used = used.filter(i => i.tf > t0s || i.td > t0l); + }; + // give a score in range [0, 1) for some position + const score = i => { + if (i.r > maxr) return -Infinity; + return 1 - Math.hypot(i.r / maxr, i.p / hc) * Math.SQRT1_2; + }; + // add some danmaku + return line => { + const { + time: t0s, // time sent (start to appear if no delay) + width: wv, // width of danmaku + height: hv, // height of danmaku + bottom: b, // is subtitle + } = line; + const t0l = wc / (wv + wc) * u + t0s; // time start to leave + syn(t0s, t0l); + const al = available(hv, t0s, t0l, b); + if (!al.length) return null; + const scored = al.map(i => [score(i), i]); + const best = scored.reduce((x, y) => { + return x[0] > y[0] ? x : y; + })[1]; + const ts = t0s + best.r; // time start to enter + const tf = wv / (wv + wc) * u + ts; // time complete enter + const td = u + ts; // time complete leave + use(best.p, best.p + hv, tf, td); + return { + top: best.p, + time: ts, + }; + }; + }; + + const fixedCanvas = function (options) { + const { + resolutionY: hc, + bottomReserved: b, + fixDuration: u, + maxDelay: maxr, + } = options; + let 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 }, + ]; + // Find out some available position + const fr = (p, m, t0s, b) => { + let tas = t0s; + used.forEach(j => { + if (j.p >= m) return; + if (j.m <= p) return; + if (j.b && b) return; + tas = Math.max(tas, j.td); + }); + const r = tas - t0s; + if (r > maxr) return null; + return { r, p, m }; + }; + // layout for danmaku at top + const top = (hv, t0s, b) => { + const suggestion = []; + used.forEach(i => { + if (i.m + hv >= hc) return; + suggestion.push(fr(i.m, i.m + hv, t0s, b)); + }); + return suggestion.filter(x => x); + }; + // layout for danmaku at bottom + const bottom = (hv, t0s, b) => { + const suggestion = []; + used.forEach(i => { + if (i.p - hv <= 0) return; + suggestion.push(fr(i.p - hv, i.p, t0s, b)); + }); + return suggestion.filter(x => x); + }; + const use = (p, m, td) => { + const l = { p, m, td, b: false }; + used.push({ p, m, td, b: false }); + }; + const syn = t0s => { + used = used.filter(i => i.td > t0s); + }; + // Score every position + const score = (i, is_top) => { + if (i.r > maxr) return -Infinity; + const f = p => is_top ? p : (hc - p); + return 1 - (i.r / maxr * (31 / 32) + f(i.p) / hc * (1 / 32)); + }; + return function (line) { + const { time: t0s, height: hv, bottom: b } = line; + const is_top = line.mode === 'TOP'; + syn(t0s); + const al = (is_top ? top : bottom)(hv, t0s, b); + if (!al.length) return null; + const scored = al.map(function (i) { return [score(i, is_top), i]; }); + const best = scored.reduce(function (x, y) { + return x[0] > y[0] ? x : y; + }, [-Infinity, null])[1]; + if (!best) return null; + use(best.p, best.m, best.r + t0s + u); + return { top: best.p, time: best.r + t0s }; + }; + }; + + const placeDanmaku = function (options) { + const normal = rtlCanvas(options), fixed = fixedCanvas(options); + return function (line) { + line.fontSize = Math.round(line.size * options.fontSize); + line.height = line.fontSize; + line.width = line.width || window.font.text(options.fontFamily, line.text, line.fontSize) || 1; + + if (line.mode === 'RTL') { + const pos = normal(line); + if (!pos) return null; + const { top, time } = pos; + line.layout = { + type: 'Rtl', + start: { + x: options.resolutionX + line.width / 2, + y: top + line.height, + time, + }, + end: { + x: -line.width / 2, + y: top + line.height, + time: options.rtlDuration + time, + }, + }; + } else if (['TOP', 'BOTTOM'].includes(line.mode)) { + const pos = fixed(line); + if (!pos) return null; + const { top, time } = pos; + line.layout = { + type: 'Fix', + start: { + x: Math.round(options.resolutionX / 2), + y: top + line.height, + time, + }, + end: { + time: options.fixDuration + time, + }, + }; + } + return line; + }; + }; + + // main layout algorithm + const main = async function (danmaku, optionGetter) { + const options = JSON.parse(JSON.stringify(optionGetter)); + const sorted = danmaku.slice(0).sort(({ time: x }, { time: y }) => x - y); + const place = placeDanmaku(options); + const result = Array(sorted.length); + let length = 0; + for (let i = 0, l = sorted.length; i < l; i++) { + let placed = place(sorted[i]); + if (placed) result[length++] = placed; + if ((i + 1) % 1000 === 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + result.length = length; + result.sort((x, y) => x.layout.start.time - y.layout.start.time); + return result; + }; + return main; + }()); + + window.danmaku = window.danmaku || {}; + window.danmaku.layout = layout; + +}()); diff --git a/extension/danmaku/parser.js b/extension/danmaku/parser.js new file mode 100644 index 0000000..d488ab3 --- /dev/null +++ b/extension/danmaku/parser.js @@ -0,0 +1,96 @@ +; (function () { + + const parser = (function () { + /** + * @typedef DanmakuColor + * @property {number} r + * @property {number} g + * @property {number} b + */ + /** + * @typedef Danmaku + * @property {string} text + * @property {number} time + * @property {string} mode + * @property {number} size + * @property {DanmakuColor} color + * @property {boolean} bottom + */ + + const parser = {}; + + /** + * @param {Danmaku} danmaku + * @returns {boolean} + */ + const danmakuFilter = danmaku => { + if (!danmaku.text) return false; + if (!danmaku.mode) return false; + if (!danmaku.size) return false; + if (danmaku.time < 0 || danmaku.time >= 360000) return false; + return true; + }; + + const parseRgb256IntegerColor = color => { + const rgb = parseInt(color, 10); + const r = (rgb >>> 4) & 0xff; + const g = (rgb >>> 2) & 0xff; + const b = (rgb >>> 0) & 0xff; + return { r, g, b }; + }; + /** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} + */ + parser.bilibili = function (content) { + const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); + const clean = text.replace(/(?:[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF])/g, ''); + const data = (new DOMParser()).parseFromString(clean, 'text/xml'); + const cid = +data.querySelector('chatid').textContent; + /** @type {Array} */ + const danmaku = Array.from(data.querySelectorAll('d')).map(d => { + const p = d.getAttribute('p'); + const [time, mode, size, color, create, bottom, sender, id] = p.split(','); + return { + text: d.textContent, + time: +time, + // We do not support ltr mode + mode: [null, 'RTL', 'RTL', 'RTL', 'BOTTOM', 'TOP'][+mode], + size: +size, + color: parseRgb256IntegerColor(color), + bottom: bottom > 0, + }; + }).filter(danmakuFilter); + return { cid, danmaku }; + }; + + /** + * @param {string|ArrayBuffer} content + * @return {{ cid: number, danmaku: Array }} + */ + parser.acfun = function (content) { + const text = typeof content === 'string' ? content : new TextDecoder('utf-8').decode(content); + const data = JSON.parse(text); + const list = data.reduce((x, y) => x.concat(y), []); + const danmaku = list.map(line => { + const [time, color, mode, size, sender, create, uuid] = line.c.split(','), text = line.m; + return { + text, + time: +time, + color: parseRgb256IntegerColor(+color), + mode: [null, 'RTL', null, null, 'BOTTOM', 'TOP'][mode], + size: +size, + bottom: false, + uuid, + }; + }).filter(danmakuFilter); + return { danmaku }; + }; + + return parser; + }()); + + window.danmaku = window.danmaku || {}; + window.danmaku.parser = parser; + +}()); diff --git a/extension/download/danmaku.ass b/extension/download/danmaku.ass new file mode 100644 index 0000000..e69de29 diff --git a/extension/download/download.js b/extension/download/download.js new file mode 100644 index 0000000..036b779 --- /dev/null +++ b/extension/download/download.js @@ -0,0 +1,67 @@ +; (function () { + + const download = window.download = window.download || {}; + + /** + * Convert file content to Blob which describe the file + * @param {string} content + * @returns {Blob} + */ + const convertToBlob = content => { + const encoder = new TextEncoder(); + // Add a BOM to make some ass parser library happier + const bom = '\ufeff'; + const encoded = encoder.encode(bom + content); + const blob = new Blob([encoded], { type: 'application/octet-stream' }); + return blob; + }; + + // A file name should not contain non-printable characters + const regOtherCharacters = /^(?:\uD834[\uDCF6-\uDCFF\uDD27\uDD28\uDD73-\uDD7A\uDDE9-\uDDFF\uDE46-\uDEFF\uDF57-\uDF5F\uDF72-\uDFFF]|\uD836[\uDE8C-\uDE9A\uDEA0\uDEB0-\uDFFF]|\uD83C[\uDC2C-\uDC2F\uDC94-\uDC9F\uDCAF\uDCB0\uDCC0\uDCD0\uDCF6-\uDCFF\uDD0D-\uDD0F\uDD2F\uDD6C-\uDD6F\uDD9B-\uDDE5\uDE03-\uDE0F\uDE3B-\uDE3F\uDE49-\uDE4F\uDE52-\uDEFF]|\uD81A[\uDE39-\uDE3F\uDE5F\uDE6A-\uDE6D\uDE70-\uDECF\uDEEE\uDEEF\uDEF6-\uDEFF\uDF46-\uDF4F\uDF5A\uDF62\uDF78-\uDF7C\uDF90-\uDFFF]|\uD809[\uDC6F\uDC75-\uDC7F\uDD44-\uDFFF]|\uD81B[\uDC00-\uDEFF\uDF45-\uDF4F\uDF7F-\uDF8E\uDFA0-\uDFFF]|\uD86E[\uDC1E\uDC1F]|\uD83D[\uDD7A\uDDA4\uDED1-\uDEDF\uDEED-\uDEEF\uDEF4-\uDEFF\uDF74-\uDF7F\uDFD5-\uDFFF]|\uD801[\uDC9E\uDC9F\uDCAA-\uDCFF\uDD28-\uDD2F\uDD64-\uDD6E\uDD70-\uDDFF\uDF37-\uDF3F\uDF56-\uDF5F\uDF68-\uDFFF]|\uD800[\uDC0C\uDC27\uDC3B\uDC3E\uDC4E\uDC4F\uDC5E-\uDC7F\uDCFB-\uDCFF\uDD03-\uDD06\uDD34-\uDD36\uDD8D-\uDD8F\uDD9C-\uDD9F\uDDA1-\uDDCF\uDDFE-\uDE7F\uDE9D-\uDE9F\uDED1-\uDEDF\uDEFC-\uDEFF\uDF24-\uDF2F\uDF4B-\uDF4F\uDF7B-\uDF7F\uDF9E\uDFC4-\uDFC7\uDFD6-\uDFFF]|\uD869[\uDED7-\uDEFF]|\uD83B[\uDC00-\uDDFF\uDE04\uDE20\uDE23\uDE25\uDE26\uDE28\uDE33\uDE38\uDE3A\uDE3C-\uDE41\uDE43-\uDE46\uDE48\uDE4A\uDE4C\uDE50\uDE53\uDE55\uDE56\uDE58\uDE5A\uDE5C\uDE5E\uDE60\uDE63\uDE65\uDE66\uDE6B\uDE73\uDE78\uDE7D\uDE7F\uDE8A\uDE9C-\uDEA0\uDEA4\uDEAA\uDEBC-\uDEEF\uDEF2-\uDFFF]|\uD87E[\uDE1E-\uDFFF]|\uDB40[\uDC00-\uDCFF\uDDF0-\uDFFF]|\uD804[\uDC4E-\uDC51\uDC70-\uDC7E\uDCBD\uDCC2-\uDCCF\uDCE9-\uDCEF\uDCFA-\uDCFF\uDD35\uDD44-\uDD4F\uDD77-\uDD7F\uDDCE\uDDCF\uDDE0\uDDF5-\uDDFF\uDE12\uDE3E-\uDE7F\uDE87\uDE89\uDE8E\uDE9E\uDEAA-\uDEAF\uDEEB-\uDEEF\uDEFA-\uDEFF\uDF04\uDF0D\uDF0E\uDF11\uDF12\uDF29\uDF31\uDF34\uDF3A\uDF3B\uDF45\uDF46\uDF49\uDF4A\uDF4E\uDF4F\uDF51-\uDF56\uDF58-\uDF5C\uDF64\uDF65\uDF6D-\uDF6F\uDF75-\uDFFF]|\uD83A[\uDCC5\uDCC6\uDCD7-\uDFFF]|\uD80D[\uDC2F-\uDFFF]|\uD86D[\uDF35-\uDF3F]|[\uD807\uD80A\uD80B\uD80E-\uD810\uD812-\uD819\uD81C-\uD82B\uD82D\uD82E\uD830-\uD833\uD837-\uD839\uD83F\uD874-\uD87D\uD87F-\uDB3F\uDB41-\uDBFF][\uDC00-\uDFFF]|\uD806[\uDC00-\uDC9F\uDCF3-\uDCFE\uDD00-\uDEBF\uDEF9-\uDFFF]|\uD803[\uDC49-\uDC7F\uDCB3-\uDCBF\uDCF3-\uDCF9\uDD00-\uDE5F\uDE7F-\uDFFF]|\uD835[\uDC55\uDC9D\uDCA0\uDCA1\uDCA3\uDCA4\uDCA7\uDCA8\uDCAD\uDCBA\uDCBC\uDCC4\uDD06\uDD0B\uDD0C\uDD15\uDD1D\uDD3A\uDD3F\uDD45\uDD47-\uDD49\uDD51\uDEA6\uDEA7\uDFCC\uDFCD]|\uD805[\uDC00-\uDC7F\uDCC8-\uDCCF\uDCDA-\uDD7F\uDDB6\uDDB7\uDDDE-\uDDFF\uDE45-\uDE4F\uDE5A-\uDE7F\uDEB8-\uDEBF\uDECA-\uDEFF\uDF1A-\uDF1C\uDF2C-\uDF2F\uDF40-\uDFFF]|\uD802[\uDC06\uDC07\uDC09\uDC36\uDC39-\uDC3B\uDC3D\uDC3E\uDC56\uDC9F-\uDCA6\uDCB0-\uDCDF\uDCF3\uDCF6-\uDCFA\uDD1C-\uDD1E\uDD3A-\uDD3E\uDD40-\uDD7F\uDDB8-\uDDBB\uDDD0\uDDD1\uDE04\uDE07-\uDE0B\uDE14\uDE18\uDE34-\uDE37\uDE3B-\uDE3E\uDE48-\uDE4F\uDE59-\uDE5F\uDEA0-\uDEBF\uDEE7-\uDEEA\uDEF7-\uDEFF\uDF36-\uDF38\uDF56\uDF57\uDF73-\uDF77\uDF92-\uDF98\uDF9D-\uDFA8\uDFB0-\uDFFF]|\uD808[\uDF9A-\uDFFF]|\uD82F[\uDC6B-\uDC6F\uDC7D-\uDC7F\uDC89-\uDC8F\uDC9A\uDC9B\uDCA0-\uDFFF]|\uD82C[\uDC02-\uDFFF]|\uD811[\uDE47-\uDFFF]|\uD83E[\uDC0C-\uDC0F\uDC48-\uDC4F\uDC5A-\uDC5F\uDC88-\uDC8F\uDCAE-\uDD0F\uDD19-\uDD7F\uDD85-\uDDBF\uDDC1-\uDFFF]|\uD873[\uDEA2-\uDFFF]|[\0-\x1F\x7F-\x9F\xAD\u0378\u0379\u0380-\u0383\u038B\u038D\u03A2\u0530\u0557\u0558\u0560\u0588\u058B\u058C\u0590\u05C8-\u05CF\u05EB-\u05EF\u05F5-\u0605\u061C\u061D\u06DD\u070E\u070F\u074B\u074C\u07B2-\u07BF\u07FB-\u07FF\u082E\u082F\u083F\u085C\u085D\u085F-\u089F\u08B5-\u08E2\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09FC-\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF2-\u0AF8\u0AFA-\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B55\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B78-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BFB-\u0BFF\u0C04\u0C0D\u0C11\u0C29\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5B-\u0C5F\u0C64\u0C65\u0C70-\u0C77\u0C80\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0D00\u0D04\u0D0D\u0D11\u0D3B\u0D3C\u0D45\u0D49\u0D4F-\u0D56\u0D58-\u0D5E\u0D64\u0D65\u0D76-\u0D78\u0D80\u0D81\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DE5\u0DF0\u0DF1\u0DF5-\u0E00\u0E3B-\u0E3E\u0E5C-\u0E80\u0E83\u0E85\u0E86\u0E89\u0E8B\u0E8C\u0E8E-\u0E93\u0E98\u0EA0\u0EA4\u0EA6\u0EA8\u0EA9\u0EAC\u0EBA\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F48\u0F6D-\u0F70\u0F98\u0FBD\u0FCD\u0FDB-\u0FFF\u10C6\u10C8-\u10CC\u10CE\u10CF\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u137D-\u137F\u139A-\u139F\u13F6\u13F7\u13FE\u13FF\u169D-\u169F\u16F9-\u16FF\u170D\u1715-\u171F\u1737-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17DE\u17DF\u17EA-\u17EF\u17FA-\u17FF\u180E\u180F\u181A-\u181F\u1878-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191F\u192C-\u192F\u193C-\u193F\u1941-\u1943\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DB-\u19DD\u1A1C\u1A1D\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1A9F\u1AAE\u1AAF\u1ABF-\u1AFF\u1B4C-\u1B4F\u1B7D-\u1B7F\u1BF4-\u1BFB\u1C38-\u1C3A\u1C4A-\u1C4C\u1C80-\u1CBF\u1CC8-\u1CCF\u1CF7\u1CFA-\u1CFF\u1DF6-\u1DFB\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FC5\u1FD4\u1FD5\u1FDC\u1FF0\u1FF1\u1FF5\u1FFF\u200B-\u200F\u202A-\u202E\u2060-\u206F\u2072\u2073\u208F\u209D-\u209F\u20BF-\u20CF\u20F1-\u20FF\u218C-\u218F\u23FB-\u23FF\u2427-\u243F\u244B-\u245F\u2B74\u2B75\u2B96\u2B97\u2BBA-\u2BBC\u2BC9\u2BD2-\u2BEB\u2BF0-\u2BFF\u2C2F\u2C5F\u2CF4-\u2CF8\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D71-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E43-\u2E7F\u2E9A\u2EF4-\u2EFF\u2FD6-\u2FEF\u2FFC-\u2FFF\u3040\u3097\u3098\u3100-\u3104\u312E-\u3130\u318F\u31BB-\u31BF\u31E4-\u31EF\u321F\u32FF\u4DB6-\u4DBF\u9FD6-\u9FFF\uA48D-\uA48F\uA4C7-\uA4CF\uA62C-\uA63F\uA6F8-\uA6FF\uA7AE\uA7AF\uA7B8-\uA7F6\uA82C-\uA82F\uA83A-\uA83F\uA878-\uA87F\uA8C5-\uA8CD\uA8DA-\uA8DF\uA8FE\uA8FF\uA954-\uA95E\uA97D-\uA97F\uA9CE\uA9DA-\uA9DD\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A\uAA5B\uAAC3-\uAADA\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F\uAB66-\uAB6F\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBC2-\uFBD2\uFD40-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFE\uFDFF\uFE1A-\uFE1F\uFE53\uFE67\uFE6C-\uFE6F\uFE75\uFEFD-\uFF00\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFDF\uFFE7\uFFEF-\uFFFB\uFFFE\uFFFF])$/g; // eslint-disable-line no-control-regex + // Windows reversed filenames + const regWindowsReservedFilename = /^(?=CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]$)/i; + // Windows dislike some characters in filename; + // Linux user may getting trouble with certain characters in filename; + // Some user cannot understand dot in a filename which is ambiguous with file extension name; + // Let us remove these characters to make everyone happier + const regFilenameUnhappyCharacters = /^[\s.-]+|[<>:"/\\|?*]|[.]|[/;#]|[\s.]+$/g; + // Filename should not be empty string, we should have something means default + const defaultFilename = 'danmaku'; + /** + * Convert the name to something valid filename + * @param {string} filename + * @returns {string} + */ + const validFilename = filename => { + const valid = filename + .match(/./ug).map(character => character.replace(regOtherCharacters, '_')).join('') + .replace(regFilenameUnhappyCharacters, '_') + .replace(regWindowsReservedFilename, '_') || + defaultFilename; + if (valid === filename) return filename; + return validFilename(valid); + }; + + const filename = function (filename, extension) { + return validFilename(filename) + '.' + extension; + }; + + download.filename = filename; + + /** + * Trigger a download with given content and filename + * @param {string} assContent + * @param {string} assFilename + */ + const getObjectUrl = function (assContent) { + const blob = convertToBlob(assContent); + const url = URL.createObjectURL(blob); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 1e4); + return url; + }; + + download.url = getObjectUrl; + +}()); diff --git a/extension/font/font.js b/extension/font/font.js new file mode 100644 index 0000000..caa87a0 --- /dev/null +++ b/extension/font/font.js @@ -0,0 +1,75 @@ +window.font = window.font || {}; + +// Meansure width of text +window.font.text = (function () { + + // Meansure using canvas + const calcWidthCanvas = function () { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + return function (fontname, text, fontsize) { + context.font = `bold ${fontsize}px ${fontname}`; + return Math.ceil(context.measureText(text).width); + }; + }; + + // Meansure using
+ const calcWidthDiv = function () { + const container = document.createElement('div'); + container.setAttribute('style', 'all: initial !important'); + const content = document.createElement('div'); + content.setAttribute('style', [ + 'top: -10000px', 'left: -10000px', + 'width: auto', 'height: auto', 'position: absolute', + ].map(item => item + ' !important;').join(' ')); + const active = () => { document.body.parentNode.appendChild(content); }; + if (!document.body) document.addEventListener('DOMContentLoaded', active); + else active(); + return (fontname, text, fontsize) => { + content.textContent = text; + content.style.font = `bold ${fontsize}px ${fontname}`; + return content.clientWidth; + }; + }; + + // https://bugzilla.mozilla.org/show_bug.cgi?id=561361 + if (/linux/i.test(navigator.platform)) { + return calcWidthDiv(); + } else { + return calcWidthCanvas(); + } + +}()); + +window.font.valid = (function () { + const cache = new Map(); + const textWidth = window.font.text; + // Use following texts for checking + const sampleText = [ + 'The quick brown fox jumps over the lazy dog', + '7531902468', ',.!-', ',。:!', + '天地玄黄', '則近道矣', + 'あいうえお', 'アイウエオガパ', 'アイウエオガパ', + ].join(''); + // Some given font family is avaliable iff we can meansure different width compared to other fonts + const sampleFont = [ + 'monospace', 'sans-serif', 'sans', + 'Symbol', 'Arial', 'Comic Sans MS', 'Fixed', 'Terminal', + 'Times', 'Times New Roman', + 'SimSum', 'Microsoft YaHei', 'PingFang SC', 'Heiti SC', 'WenQuanYi Micro Hei', + 'Pmingliu', 'Microsoft JhengHei', 'PingFang TC', 'Heiti TC', + 'MS Gothic', 'Meiryo', 'Hiragino Kaku Gothic Pro', 'Hiragino Mincho Pro', + ]; + const diffFont = function (base, test) { + const baseSize = textWidth(base, sampleText, 72); + const testSize = textWidth(test + ',' + base, sampleText, 72); + return baseSize !== testSize; + }; + const validFont = function (test) { + if (cache.has(test)) return cache.get(test); + const result = sampleFont.some(base => diffFont(base, test)); + cache.set(test, result); + return result; + }; + return validFont; +}()); diff --git a/extension/icon/danmaku.svg b/extension/icon/danmaku.svg new file mode 100644 index 0000000..3ef1ba4 --- /dev/null +++ b/extension/icon/danmaku.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..de1b374 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,58 @@ +{ + + "manifest_version": 2, + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "version": "1.0", + "default_locale": "en", + + "icons": { + "48": "icon/danmaku.svg" + }, + + "permissions": [ + "webRequest", "webRequestBlocking", + "tabs", + "downloads", + "storage", + "" + ], + + "background": { + "scripts": [ + "background/main.js", + "font/font.js", + "options/ext_options.js", + "danmaku/parser.js", + "danmaku/ass.js", + "danmaku/layout.js", + "download/download.js", + "background/acfun.js", + "background/bilibili.js" + ] + }, + + "page_action": { + "browser_style": true, + "default_popup": "popup/popup.html", + "default_title": "__MSG_extensionButtonTitle__", + "default_icon": "icon/danmaku.svg" + }, + + "options_ui": { + "browser_style": true, + "page": "options/options.html" + }, + + "web_accessible_resources": [ + "download/danmaku.ass" + ], + + "applications": { + "gecko": { + "id": "{d9a7d273-90af-49c2-9379-887fe4848372}", + "strict_min_version": "57.0a1" + } + } + +} diff --git a/extension/options/ext_options.js b/extension/options/ext_options.js new file mode 100644 index 0000000..7217a3d --- /dev/null +++ b/extension/options/ext_options.js @@ -0,0 +1,143 @@ +/** + * @file Common works for reading / writing optinos + */ + +window.options = (function () { + + const optionKey = 'options'; + + /** + * @typedef {ExtOption} + * @property {number} resolutionX canvas width for drawing danmaku (px) + * @property {number} resolutionY canvas height for drawing danmaku (px) + * @property {number} bottomReserved reserved height at bottom for drawing danmaku (px) + * @property {string} fontFamily danmaku font family + * @property {number} fontSize danmaku font size (ratio) + * @property {number} textSpace space between danmaku (px) + * @property {number} rtlDuration duration of right to left moving danmaku appeared on screen (s) + * @property {number} fixDuration duration of keep bottom / top danmaku appeared on screen (s) + * @property {number} maxDelay // maxinum amount of allowed delay (s) + * @property {number} textOpacity // opacity of text, in range of [0, 1] + */ + + /** @type {ExtOption} */ + const options = {}; + + /** + * @returns {string} + */ + const predefFontFamily = () => { + const sc = ['Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC']; + const tc = ['Microsoft JhengHei', 'PingFang TC', 'Noto Sans CJK TC']; + const ja = ['MS PGothic', 'Hiragino Kaku Gothic Pro', 'Noto Sans CJK JP']; + const lang = browser.i18n.getUILanguage; + const fonts = /^ja/.test(lang) ? ja : /^zh(?!.*Hans).*(?:TW|HK|MO|)/.test(lang) ? tc : sc; + const chosed = fonts.find(font => window.font.valid(font)) || fonts[0]; + return chosed; + }; + + const attributes = [ + { name: 'resolutionX', type: 'number', min: 480, predef: 560 }, + { name: 'resolutionY', type: 'number', min: 360, predef: 420 }, + { name: 'bottomReserved', type: 'number', min: 0, predef: 60 }, + { name: 'fontFamily', type: 'string', predef: predefFontFamily(), valid: font => window.font.valid(font) }, + { name: 'fontSize', type: 'number', min: 0, predef: 1, step: 0.01 }, + { name: 'textSpace', type: 'number', min: 0, predef: 0 }, + { name: 'rtlDuration', type: 'number', min: 0.1, predef: 8, step: 0.1 }, + { name: 'fixDuration', type: 'number', min: 0.1, predef: 4, step: 0.1 }, + { name: 'maxDelay', type: 'number', min: 0, predef: 6, step: 0.1 }, + { name: 'textOpacity', type: 'number', min: 10, max: 100, predef: 60 }, + ]; + + const attrNormalize = (option, { name, type, min = -Infinity, max = Infinity, step = 1, predef, valid }) => { + let value = option; + if (type === 'number') value = +value; + else if (type === 'string') value = '' + value; + if (valid && !valid(value)) value = predef; + if (type === 'number') { + if (Number.isNaN(value)) value = predef; + if (value < min) value = min; + if (value > max) value = max; + value = Math.round((value - min) / step) * step + min; + } + return value; + }; + + /** + * @param {ExtOption} option + * @returns {ExtOption} + */ + const normalize = async function (option) { + return Object.assign({}, + ...attributes.map(attr => ({ [attr.name]: attrNormalize(option[attr.name], attr) })) + ); + }; + + /** + * @param {ExtOption} option + * @returns {Promise.} + */ + const set = async function (proxied) { + const write = { [optionKey]: JSON.stringify(proxied) }; + await browser.storage.sync.set(write); + return proxied; + }; + + /** + * @returns {Promise.} + */ + const get = async function () { + let option; + try { + const read = (await browser.storage.sync.get({ [optionKey]: '{}' }))[optionKey]; + option = JSON.parse(read); + } catch (e) { option = {}; } + const normalized = normalize(option); + if (JSON.stringify(normalized) !== JSON.stringify(option)) set(normalized); + return normalized; + }; + + const proxy = function () { + const proxied = new Proxy(options, { + get: (options, prop) => { + const attr = attributes.find(({ name }) => name === prop); + return attrNormalize(options[prop], attr); + }, + set: (option, prop, value) => { + const attr = attributes.find(({ name }) => name === prop); + option[prop] === value; + Object.assign(option, attr ? { [attr.name]: attrNormalize(option[prop], attr) } : {}); + }, + }); + }; + + /** + * @param {ExtOption} option + * @param {HTMLElement} dom + */ + const bindDom = function (proxied, dom) { + const values = Array.from(dom.querySelectorAll('[data-value]')); + Array.from(dom.querySelectorAll('input[name]')).forEach(element => { + const name = element.getAttribute('name'); + const outputs = values.filter(output => output.dataset.value === name); + + const attr = attributes.find(({ name: attr }) => attr === name); + element.addEventListener('input', event => { + const option = element.value; + const normalized = attrNormalize(option, attr); + proxied[name] = normalized; + set(proxied); + outputs.forEach(output => { output.value = normalized; }); + }); + element.addEventListener('blur', event => { + element.value = proxied[name]; + }); + element.value = proxied[name]; + outputs.forEach(output => { output.value = proxied[name]; }); + }); + return proxied; + }; + + return { get, bindDom }; + +}()); diff --git a/extension/options/options.css b/extension/options/options.css new file mode 100644 index 0000000..120f07a --- /dev/null +++ b/extension/options/options.css @@ -0,0 +1,22 @@ +.detail-rows { + font: 15px message-box; + line-height: 20px; + text-shadow: 0 1px 1px #fefffe; + display: grid; + grid-template-columns: 1fr 2fr; +} + +.detail-row { + display: contents; +} + +.detail-row > label, .detail-row > .browser-style { + margin-top: 1px; + margin-bottom: 2px; + margin-inline-start: 6px; + margin-inline-end: 5px; +} + +input[type="range"] { + margin: 0; +} diff --git a/extension/options/options.html b/extension/options/options.html new file mode 100644 index 0000000..a3a1d36 --- /dev/null +++ b/extension/options/options.html @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + diff --git a/extension/options/options.js b/extension/options/options.js new file mode 100644 index 0000000..9fd23e5 --- /dev/null +++ b/extension/options/options.js @@ -0,0 +1,21 @@ +; (function () { + + document.addEventListener('DOMContentLoaded', async function () { + /** @type {HTMLTemplateElement} */ + const panel = document.getElementById('config_panel'); + const content = panel.content; + const main = content.querySelector('main'); + const placeholders = Array.from(main.querySelectorAll('span[data-i18n]')); + placeholders.forEach(span => { + const i18n = span.dataset.i18n; + delete span.dataset.i18n; + const text = browser.i18n.getMessage(i18n); + span.textContent = text; + }); + const instance = document.importNode(main); + const options = await window.options.get(); + window.options.bindDom(options, main); + panel.parentNode.insertBefore(main, panel); + }); + +}()); diff --git a/extension/popup/popup.css b/extension/popup/popup.css new file mode 100644 index 0000000..927ff8e --- /dev/null +++ b/extension/popup/popup.css @@ -0,0 +1,12 @@ +/* I don't know why, but it seems that a simple transform make render happier */ +.panel { + transform: scale(1); + min-width: 240px; + max-width: 320px; + white-space: nowrap; +} + +.panel .text { + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/extension/popup/popup.html b/extension/popup/popup.html new file mode 100644 index 0000000..da65298 --- /dev/null +++ b/extension/popup/popup.html @@ -0,0 +1,17 @@ + + + + + + + + + +
+ + + + + diff --git a/extension/popup/popup.js b/extension/popup/popup.js new file mode 100644 index 0000000..8809807 --- /dev/null +++ b/extension/popup/popup.js @@ -0,0 +1,52 @@ +; (function () { + + const activeTab = browser.tabs.query({ currentWindow: true, active: true }).then(([tab]) => tab); + + const callBackend = new Proxy({}, { + get: (empty, method) => (...params) => ( + activeTab.then(({ id }) => ( + browser.runtime.sendMessage({ method, params: [id, ...params] }) + )) + ), + }); + + const domReady = new Promise(resolve => { + document.addEventListener('DOMContentLoaded', () => resolve()); + }); + + const danmakuInfo = callBackend.listDanmaku(); + + + Promise.all([domReady, danmakuInfo]).then(([, danmakuList]) => { + const menuList = document.querySelector('.download-list'); + const menuItems = document.createDocumentFragment(); + danmakuList.forEach(({ id, meta: { name } }) => { + /** @type {HTMLTemplateElement} */ + const template = document.querySelector('#download-item-template'); + const content = template.content; + const menuText = content.querySelector('.text'); + menuText.textContent = name; + const menuItem = content.querySelector('.download-item'); + menuItem.dataset.id = id; + menuItems.appendChild(document.importNode(menuItem, true)); + }); + menuList.appendChild(menuItems); + }); + + domReady.then(() => { + document.addEventListener('click', event => { + const target = event.target; + const downloadItem = target.closest && target.closest('.download-item'); + const id = downloadItem && downloadItem.dataset.id; + if (!id) return; + const converting = document.createElement('span'); + converting.textContent = browser.i18n.getMessage('downloadingTip'); + const text = downloadItem.querySelector('.text'); + text.insertBefore(converting, text.firstChild); + callBackend.downloadDanmaku(id).then(() => { + converting.parentNode.removeChild(converting); + }); + }); + }); + +}());