├── .eslintignore ├── pages └── settings │ ├── assets │ ├── tab-component.css │ └── fonts.css │ ├── index.js │ ├── plugins │ └── vuetify.js │ ├── components │ ├── AboutTabComponent.vue │ ├── dialog │ │ └── PluginUpdateDialog.vue │ ├── RecordTabComponent.vue │ └── BasicTabComponent.vue │ ├── index.tpl │ └── App.vue ├── .gitignore ├── .github ├── screenshot.png └── workflows │ └── build.yml ├── .babelrc ├── src ├── util │ ├── storage.js │ ├── clipboard.js │ └── recorder.js ├── lang │ ├── zh-CN.js │ └── zh-TW.js ├── constants.js ├── ui.js └── index.js ├── types └── tampermonkey.d.ts ├── .eslintrc.js ├── LICENSE ├── common ├── constants.js └── store.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | -------------------------------------------------------------------------------- /pages/settings/assets/tab-component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .idea 4 | -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fython/userscript-enhance-bilibili-player/HEAD/.github/screenshot.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-transform-runtime" 7 | ] 8 | } -------------------------------------------------------------------------------- /pages/settings/assets/fonts.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500&display=swap'); 2 | 3 | body { 4 | font-family: 'Noto Sans SC', 'Microsoft YaHei UI', Arial, sans-serif 5 | } 6 | -------------------------------------------------------------------------------- /pages/settings/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import vuetify from './plugins/vuetify'; 3 | import App from './App.vue'; 4 | import './assets/fonts.css'; 5 | 6 | new Vue({ 7 | render: h => h(App), 8 | vuetify, 9 | }).$mount('#app'); 10 | -------------------------------------------------------------------------------- /src/util/storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 读取 Cookie 键值 3 | * @param {string} key Cookie 键 4 | * @returns {string | undefined} 5 | */ 6 | export function getCookie(key) { 7 | const keyValue = document.cookie.match(`(^|;) ?${key}=([^;]*)(;|$)`); 8 | return keyValue ? keyValue[2] : null; 9 | } 10 | 11 | /** 12 | * 获取界面语言 13 | * @returns {('zh-CN'|'zh-TW')} 14 | */ 15 | export function getLanguage() { 16 | return getCookie('LNG') || 'zh-CN'; 17 | } -------------------------------------------------------------------------------- /pages/settings/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | import colors from 'vuetify/lib/util/colors'; 4 | 5 | Vue.use(Vuetify); 6 | 7 | export default new Vuetify({ 8 | theme: { 9 | themes: { 10 | light: { 11 | primary: colors.teal, 12 | secondary: colors.teal.darken3, 13 | accent: colors.shades.black, 14 | error: colors.red.accent3 15 | }, 16 | dark: { 17 | primary: colors.teal.darken1, 18 | } 19 | } 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /pages/settings/components/AboutTabComponent.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 关于 4 | 5 | 哔哩哔哩增强插件可以为你提供更好的网页端视频播放体验,在使用本设置之前你需要先安装最新的 6 | 插件本体。 7 | 8 | 9 | 如果插件在你的浏览器上不工作,可能的原因如下: 10 | 11 | 12 | 13 | 浏览器版本过旧,未被插件的向下兼容覆盖,推荐使用最新的 Chrome 浏览器以获得最佳体验。 14 | 哔哩哔哩页面已经改版,如果插件已经是最新版,请联系作者进行更新插件。 15 | 16 | 17 | 18 | 19 | 20 | 27 | -------------------------------------------------------------------------------- /types/tampermonkey.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * TamperMonkey 全局方法声明 3 | * 4 | * @author Siubeng 5 | */ 6 | 7 | declare function GM_setValue(name: string, value: T | null); 8 | 9 | declare function GM_getValue(name: string, defaultValue?: T): T; 10 | 11 | declare function GM_addValueChangeListener(name: string, func: ValueChangeListener); 12 | 13 | declare function GM_openInTab(url: string, option?: boolean | GM_OpenInTabOptions); 14 | 15 | declare interface GM_OpenInTabOptions { 16 | active?: boolean; 17 | insert?: boolean; 18 | setParent?: boolean; 19 | incognito?: boolean; 20 | } 21 | 22 | type ValueChangeListener = (name: string, old_value: T | null, new_value: T | null, remote: boolean) => any; 23 | -------------------------------------------------------------------------------- /src/lang/zh-CN.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ACTION_COPY_URL_WITH_TIMESTAMP: '复制当前时间的视频链接', 3 | ACTION_PIP: '弹出画中画播放', 4 | ACTION_COPY_SCREENSHOT: '复制当前时间的视频截图(视频实际分辨率/无弹幕)', 5 | ACTION_SETTINGS: '增强插件设置', 6 | ACTION_RECORD_START: '开始录制', 7 | ACTION_RECORD_STOP: '停止录制并保存', 8 | TOAST_COPY_URL_WITH_TIMESTAMP_DONE: (time) => `已复制当前位置(${time})的视频链接到剪贴板。`, 9 | TOAST_COPY_URL_FAILED: '复制链接失败,您的浏览器可能不允许脚本直接修改剪贴板。', 10 | TOAST_PIP_UNSUPPORTED: '您的浏览器暂不支持画中画播放,可能需要最新的 Chrome。', 11 | TOAST_COPY_SCREENSHOT_DONE: '已复制当前位置的视频截图到剪贴板。', 12 | TOAST_COPY_SCREENSHOT_NOT_READY: '视频仍在加载状态,请稍后再尝试复制截图。', 13 | TOAST_COPY_SCREENSHOT_FAILED: '复制视频截图失败,查看日志可了解详情。', 14 | TOAST_RECORD_STARTED: '录制已开始,打开菜单可停止并保存。', 15 | TOAST_RECORD_START_FAILED: '错误!您的浏览器可能不支持录制或格式选择有误。', 16 | }; 17 | -------------------------------------------------------------------------------- /src/lang/zh-TW.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ACTION_COPY_URL_WITH_TIMESTAMP: '拷貝當前時間的視訊鏈接', 3 | ACTION_PIP: '彈出子母畫面播放', 4 | ACTION_COPY_SCREENSHOT: '拷貝當前時間的視訊擷圖(視訊實際解析度/無彈幕)', 5 | ACTION_SETTINGS: '增強插件設定', 6 | ACTION_RECORD_START: '開始錄影', 7 | ACTION_RECORD_STOP: '停止錄影并存檔', 8 | TOAST_COPY_URL_WITH_TIMESTAMP_DONE: (time) => `已拷貝當前位置(${time})的視訊鏈接至剪貼板。`, 9 | TOAST_COPY_URL_FAILED: '拷貝鏈接失敗,您的瀏覽器可能不允許腳本直接修改。', 10 | TOAST_PIP_UNSUPPORTED: '您的瀏覽器暫不支援子母畫面播放,可能需要最新的 Chrome。', 11 | TOAST_COPY_SCREENSHOT_DONE: '已拷貝當前位置的視訊擷圖至剪貼板。', 12 | TOAST_COPY_SCREENSHOT_NOT_READY: '視訊仍在加載狀態,請稍後再嘗試拷貝擷圖。', 13 | TOAST_COPY_SCREENSHOT_FAILED: '拷貝視訊擷圖失敗,檢視日誌可了解詳情。', 14 | TOAST_RECORD_STARTED: '錄影已開始,打開菜單可停止並存檔。', 15 | TOAST_RECORD_START_FAILED: '錯誤!您的瀏覽器可能不支援錄影或格式選擇有誤。', 16 | }; 17 | -------------------------------------------------------------------------------- /pages/settings/index.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 11 | 12 | 13 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue. 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es6': true, 5 | 'jquery': true, 6 | 'greasemonkey': true 7 | }, 8 | 'extends': 'eslint:recommended', 9 | 'globals': { 10 | 'Atomics': 'readonly', 11 | 'SharedArrayBuffer': 'readonly', 12 | 'ClipboardItem': 'readonly', 13 | 'EnhancePluginStore_instance': 'readonly', 14 | 'GM_addValueChangeListener': 'readonly', 15 | 'bvid': 'readonly', 16 | }, 17 | 'parserOptions': { 18 | 'ecmaVersion': 2018, 19 | 'sourceType': 'module' 20 | }, 21 | 'rules': { 22 | 'indent': [ 23 | 'error', 24 | 4 25 | ], 26 | 'linebreak-style': 'off', 27 | 'quotes': [ 28 | 'error', 29 | 'single' 30 | ], 31 | 'semi': [ 32 | 'error', 33 | 'always' 34 | ] 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /pages/settings/components/dialog/PluginUpdateDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 不支持的插件版本 5 | 6 | 设置页面需要搭配最新的插件本体,请及时升级获得最佳体验。 7 | 8 | 9 | 10 | 忽略 11 | 升级插件 12 | 13 | 14 | 15 | 16 | 17 | 30 | 31 | 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build latest releases 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup Node 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: '10.x' 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v1 23 | with: 24 | path: ~/.npm 25 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | ${{ runner.os }}-node- 28 | 29 | - run: npm ci 30 | 31 | - run: npm run build 32 | 33 | - name: Deploy 34 | uses: peaceiris/actions-gh-pages@v3 35 | with: 36 | personal_token: ${{ secrets.ACCESS_TOKEN }} 37 | publish_dir: ./dist 38 | publish_branch: gh-pages 39 | user_name: Siubeng Fung 40 | user_email: fython@163.com 41 | cname: biliplayer.gwo.app 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Siubeng Fung (fython) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /common/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @enum {string} 3 | */ 4 | export const Settings = { 5 | MENU_SHOW_RATIO: 'menu_show_ratio', 6 | MENU_SHOW_PLAYBACK_SPEED: 'menu_show_playback_speed', 7 | MENU_SHOW_LIGHT_OFF: 'menu_show_light_off', 8 | MENU_SHOW_MIRROR: 'menu_show_mirror', 9 | MENU_SHOW_KEYMAP: 'menu_show_keymap', 10 | MENU_SHOW_CHANGELOG: 'menu_show_changelog', 11 | MENU_SHOW_COLOR_AND_SFX: 'menu_show_color_and_sfx', 12 | MENU_SHOW_VIDEO_INFO: 'menu_show_video_info', 13 | MENU_SHOW_COPY_TS_URL: 'menu_show_copy_ts_url', 14 | MENU_SHOW_COPY_SCREENSHOT: 'menu_show_copy_screenshot', 15 | MENU_SHOW_RECORD: 'menu_show_record', 16 | LIVE_MENU_SHOW_PIP: 'menu_show_pip', 17 | TS_URL_STYLE: 'timestamp_url_style', 18 | SCREENSHOT_FORMAT: 'screenshot_format', 19 | SCREENSHOT_QUALITY: 'screenshot_quality', 20 | RECORD_MIME_TYPE: 'record_mime_type', 21 | CLEAN_URL: 'clean_url', 22 | TS_USE_MICROSECONDS: 'timestamp_use_microseconds', 23 | }; 24 | 25 | /** 26 | * @enum {number} 27 | */ 28 | export const TimestampStyle = { 29 | HMS: 0, 30 | ONLY_SEC: 1, 31 | }; 32 | 33 | /** 34 | * @enum {string} 35 | */ 36 | export const MimeTypes = { 37 | MP4: 'video/mp4', 38 | MP3: 'audio/mpeg', 39 | WEBM: 'video/webm', 40 | MKV: 'video/x-matroska', 41 | OGG: 'audio/ogg', 42 | WEBM_AUDIO: 'audio/webm', 43 | }; 44 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | import zh_CN from './lang/zh-CN'; 2 | import zh_TW from './lang/zh-TW'; 3 | import { Settings } from '../common/constants'; 4 | 5 | const SELECTORS = { 6 | PLAYER: '#bilibiliPlayer', 7 | PLAYER_INNER_CONTAINER: '#playerWrap > div > .player', 8 | PLAYER_WRAPPER: '#playerWrap div', 9 | PLAYER_MODULE: '#player_module div', 10 | MENU: 'div.bilibili-player-context-menu-container.black.bilibili-player-context-menu-origin', 11 | TOAST_CONTAINER: 'div.bilibili-player-video-toast-bottom', 12 | VIDEO_TITLE: 'h1.video-title', 13 | DANMAKU_CONTEXT_MENU: '.context-menu-danmaku', 14 | LIVE_PLAYER: '.bilibili-live-player', 15 | LIVE_MENU: 'div.bilibili-live-player-context-menu-container', 16 | }; 17 | 18 | const IDS = { 19 | MENU_COPY_TS_URL: 'copy-ts-menu-action-item', 20 | MENU_PIP: 'pip-action-item', 21 | MENU_SCREENSHOT: 'screenshot-action-item', 22 | MENU_RECORD: 'record-action-item', 23 | MENU_SETTINGS: 'settings-action-item', 24 | TOAST: 'enhance-bili-toast', 25 | }; 26 | 27 | const TEXT = { 28 | 'zh-CN': zh_CN, 29 | 'zh-TW': zh_TW, 30 | }; 31 | 32 | const HIDDEN_KEYWORDS = { 33 | [Settings.MENU_SHOW_RATIO]: '画面比例', 34 | [Settings.MENU_SHOW_PLAYBACK_SPEED]: '播放速度', 35 | [Settings.MENU_SHOW_LIGHT_OFF]: '关灯', 36 | [Settings.MENU_SHOW_MIRROR]: '镜像', 37 | [Settings.MENU_SHOW_KEYMAP]: '快捷键说明', 38 | [Settings.MENU_SHOW_CHANGELOG]: '更新历史', 39 | [Settings.MENU_SHOW_COLOR_AND_SFX]: '视频色彩调整', 40 | [Settings.MENU_SHOW_VIDEO_INFO]: '视频统计信息', 41 | }; 42 | 43 | const LIVE_URL_PATTERN = /http(s)?:\/\/live\.bilibili\.com\/.+/; 44 | 45 | const USEFUL_VIDEO_URL_PARAMS = [ 46 | 'p' 47 | ]; 48 | 49 | export { 50 | SELECTORS, 51 | IDS, 52 | TEXT, 53 | HIDDEN_KEYWORDS, 54 | LIVE_URL_PATTERN, 55 | USEFUL_VIDEO_URL_PARAMS, 56 | }; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "userscript-enhance-bilibili-player", 3 | "version": "0.3.1", 4 | "description": "Enhance Bilibili Player experience", 5 | "main": "src/index.js", 6 | "types": "types", 7 | "scripts": { 8 | "build": "npm run build:userscript && npm run build:settings", 9 | "build:settings": "cross-env NODE_ENV=production webpack --config build/webpack.settings.config.js", 10 | "build:userscript": "cross-env NODE_ENV=production webpack --config build/webpack.userscript.config.js", 11 | "dev:settings": "cross-env NODE_ENV=development LOCAL_DEV=1 webpack-dev-server --debug --watch --config build/webpack.settings.config.js", 12 | "dev:userscript": "cross-env NODE_ENV=development LOCAL_DEV=1 webpack-dev-server --debug --watch --config build/webpack.userscript.config.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/fython/userscript-enhance-bilibili-player.git" 17 | }, 18 | "keywords": [ 19 | "tampermonkey", 20 | "bilibili" 21 | ], 22 | "author": "Siubeng", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/fython/userscript-enhance-bilibili-player/issues" 26 | }, 27 | "homepage": "https://github.com/fython/userscript-enhance-bilibili-player#readme", 28 | "devDependencies": { 29 | "@babel/core": "^7.12.10", 30 | "@babel/plugin-transform-runtime": "^7.12.10", 31 | "@babel/preset-env": "^7.12.11", 32 | "babel-loader": "^8.2.2", 33 | "cross-env": "^7.0.3", 34 | "css-loader": "^3.4.2", 35 | "deepmerge": "^4.2.2", 36 | "eslint": "^6.8.0", 37 | "eslint-loader": "^3.0.3", 38 | "fibers": "^4.0.2", 39 | "html-webpack-plugin": "^4.5.1", 40 | "sass": "^1.32.5", 41 | "sass-loader": "^8.0.2", 42 | "vue-loader": "^15.9.6", 43 | "vue-style-loader": "^4.1.2", 44 | "vue-template-compiler": "^2.6.12", 45 | "vuetify-loader": "^1.6.0", 46 | "webpack": "^4.46.0", 47 | "webpack-cli": "^3.3.10", 48 | "webpack-dev-server": "^3.11.3", 49 | "webpack-userscript": "^2.5.3" 50 | }, 51 | "dependencies": { 52 | "@babel/runtime": "^7.12.5", 53 | "jquery": "^3.5.1", 54 | "vue": "^2.6.12", 55 | "vuetify": "^2.4.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/util/clipboard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 复制文本 3 | * @param {string} text 4 | * @returns {Promise} 是否成功 5 | */ 6 | async function copyText(text) { 7 | // Latest Clipboard API 8 | if (navigator.clipboard && navigator.clipboard.writeText) { 9 | return await navigator.clipboard.writeText(text).then(() => true, () => false); 10 | } 11 | // Internet Explorer-specific code 12 | if (window.clipboardData && window.clipboardData.setData) { 13 | // Internet Explorer-specific code path to prevent textarea being shown while dialog is visible. 14 | return window.clipboardData.setData('Text', text); 15 | } 16 | // Common copy path 17 | if (document.queryCommandSupported && document.queryCommandSupported('copy')) { 18 | const textarea = document.createElement('textarea'); 19 | textarea.textContent = text; 20 | // Prevent scrolling to bottom of page in Microsoft Edge. 21 | textarea.style.position = 'fixed'; 22 | document.body.appendChild(textarea); 23 | textarea.select(); 24 | try { 25 | // Security exception may be thrown by some browsers. 26 | return document.execCommand('copy'); 27 | } catch (ex) { 28 | console.warn('Failed to copy to clipboard', ex); 29 | return false; 30 | } finally { 31 | document.body.removeChild(textarea); 32 | } 33 | } 34 | } 35 | 36 | /** 37 | * 检查是否支持最新的 ClipboardItem API 38 | * @returns {boolean} 是否支持 39 | */ 40 | function isSupportClipboardItemAPI() { 41 | return navigator.clipboard && navigator.clipboard.write && (typeof ClipboardItem === 'function'); 42 | } 43 | 44 | /** 45 | * 复制 Canvas 元素中的内容到剪贴板,使用前请检查是否支持 API 46 | * @param {HTMLCanvasElement} canvas HTML Canvas 元素 47 | * @param {string} [type=image/png] 输出的图像格式 48 | * @param {number?} quality 质量 49 | */ 50 | function copyCanvasImage(canvas, type = 'image/png', quality) { 51 | return new Promise((resolve, reject) => { 52 | canvas.toBlob((blob) => { 53 | const item = new ClipboardItem({ [type]: blob }); 54 | navigator.clipboard.write([item]).then(resolve, reject); 55 | }, type, quality); 56 | }); 57 | } 58 | 59 | export { 60 | copyText, 61 | isSupportClipboardItemAPI, 62 | copyCanvasImage, 63 | }; -------------------------------------------------------------------------------- /pages/settings/components/RecordTabComponent.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 录制 4 | 5 | 6 | 7 | 录制格式 8 | 9 | 10 | mdi-content-save 11 | 12 | 13 | 封装格式 14 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 不支持的封装格式 32 | 33 | 您的浏览器上的 MediaRecorder API 34 | 似乎并不支持 {{ currentRecordMimeType }} 这个封装格式,如果录制时遇到错误,请记得回来这里更换格式。 35 | 36 | 37 | 38 | 我知道了 39 | 40 | 41 | 42 | 43 | 44 | 45 | 88 | 89 | 92 | -------------------------------------------------------------------------------- /pages/settings/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 哔哩哔哩播放器增强插件 6 | 7 | 8 | mdi-github 9 | 10 | 11 | 12 | 13 | 14 | 正在等待插件加载…… 15 | 16 | 似乎加载不到插件,你确认已经安装哔哩哔哩播放器增强插件了吗? 17 | 18 | 19 | 20 | 21 | mdi-tune 22 | 基本 23 | 24 | 25 | mdi-video 26 | 录制 27 | 28 | 29 | mdi-information-outline 30 | 关于 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 87 | 88 | 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 增强哔哩哔哩播放器 2 | ====== 3 | 4 | [](https://github.com/fython/userscript-enhance-bilibili-player/blob/master/LICENSE) 5 | 6 | ## 介绍 7 | 8 | 这是一个用于增强哔哩哔哩网页播放器使用体验的 Tampermonkey 插件。 9 | 10 | ### 插件功能 11 | 12 | 目前哔哩哔哩播放器增强插件实现的功能如下: 13 | 14 | - 按需隐藏原播放器菜单不常用的选项 15 | - 复制当前播放位置的视频链接,可选 `?t=12h34m56s`(时分秒)或 `?t=1234`(秒数)不同的参数风格 16 | - 以视频实际分辨率、不带弹幕,复制当前播放位置的视频截图 17 | - 对当前播放内容进行录制 18 | - 插件设置页面 19 | 20 | ### 使用场景 21 | 22 | 其一:在播放器点击鼠标右键打开菜单,即可复制当前播放位置的视频链接发给你的朋友,对方打开这个链接时会直接跳转到指定位置,和 YouTube 的使用体验相同。 23 | 24 |  25 | 26 | ## 安装 27 | 28 | 在使用之前请先确保你已经在浏览器安装了 [Tampermonkey/油猴](https://www.tampermonkey.net/?ext=dhdg&locale=zh),如果你无法通过官方的 Chrome Store 渠道获取,请使用国内的搜索引擎搜索油猴的离线安装版。 29 | 30 | 然后在 [【GreasyFork: 397885-哔哩哔哩播放器增强】](https://greasyfork.org/zh-CN/scripts/397885-%E5%93%94%E5%93%A9%E5%93%94%E5%93%A9%E6%92%AD%E6%94%BE%E5%99%A8%E5%A2%9E%E5%BC%BA) 进行安装。 31 | 32 | ## 开发 & 编译 33 | 34 | ### 安装依赖 35 | 36 | 在获取源码到本地后,你需要保证开发环境已安装 Node.js 和 NPM。 37 | 38 | 然后在项目目录执行 `npm install` 安装编译所需要的依赖。 39 | 40 | ### 运行调试 41 | 42 | 执行 `npm run dev:userscript` 进行插件的测试版本编译,在已安装 Tampermonkey 插件的浏览器中打开 `http://127.0.0.1:10801/enhance-biliplayer.user.js` 即可安装当前编译的插件程序,每次热更新后都需要重新打开这个地址更新插件。 43 | 44 | 执行 `npm run dev:settings` 运行插件设置页面,默认是 `http://127.0.0.1:10802`。正式版 `production` 编译的插件不会连接到本地运行的设置,请配合上面的测试版本插件使用。 45 | 46 | 执行 `npm run build` 对脚本进行打包输出,编译结果将存放在项目目录的 `dist` 中。 47 | 48 | ### 贡献代码 49 | 50 | 欢迎提出 Issues 或 Pull Request,提交分支请选择 `dev` 分支,`master` 为正式发布分支,一旦提交到 `master` 就会执行 GitHub Actions 发布新的正式版本到 `gh-pages` 和 GreasyFork 仓库中。 51 | 52 | 项目结构如下: 53 | 54 | ``` 55 | | .github : 存放 Markdown 引用的媒体资源和 GitHub Actions 定义 56 | | build : Webpack 编译设置 57 | | common : 【插件本体与设置页面的共享源码(常量、存储)】 58 | | pages : 外部页面源码 59 | | |- settings : 【插件设置页面源码】 60 | | |- assets : 设置页面样式、资源 61 | | |- components : 页面组件 62 | | |- plugins : Vue 插件 63 | | |- App.vue : 设置主页面 Vue 源码 64 | | |- index.js : 主入口 65 | | |- index.tpl : 页面模版 66 | | 67 | | src : 【插件本体源码】 68 | | |- lang : 文本翻译 69 | | |- util : 工具集 70 | | |- constants.js : 插件本体常量 71 | | |- index.js : 插件入口、实现的功能定义 72 | | |- ui.js : 插件界面定义 73 | | 74 | | .babelrc : Babel 配置 75 | | .eslintignore : ESLint 忽略检查清单 76 | | .eslintrc.js : ESLint 配置 77 | | package.json : NPM 包定义 78 | ``` 79 | 80 | ## License 81 | 82 | ``` 83 | MIT License 84 | 85 | Copyright (c) 2020 Siubeng Fung (fython) 86 | 87 | Permission is hereby granted, free of charge, to any person obtaining a copy 88 | of this software and associated documentation files (the "Software"), to deal 89 | in the Software without restriction, including without limitation the rights 90 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 91 | copies of the Software, and to permit persons to whom the Software is 92 | furnished to do so, subject to the following conditions: 93 | 94 | The above copyright notice and this permission notice shall be included in all 95 | copies or substantial portions of the Software. 96 | 97 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 98 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 99 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 100 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 101 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 102 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 103 | SOFTWARE. 104 | ``` 105 | -------------------------------------------------------------------------------- /common/store.js: -------------------------------------------------------------------------------- 1 | import { Settings, TimestampStyle } from './constants'; 2 | 3 | class EnhancePluginStore { 4 | /** 5 | * 获取能够调用 GM API 的实例(仅设置页面调用) 6 | * @returns {Promise} 7 | */ 8 | static getInstance() { 9 | return new Promise((resolve) => { 10 | const callback = setInterval(() => { 11 | if (typeof EnhancePluginStore_instance === 'object') { 12 | clearInterval(callback); 13 | resolve(EnhancePluginStore_instance); 14 | } 15 | }, 200); 16 | }); 17 | } 18 | 19 | /** 20 | * 返回 Store 类当前版本 21 | * @returns {number} 22 | */ 23 | static get version() { 24 | return 2; 25 | } 26 | 27 | /** 28 | * 返回 Store 实体的版本,用于判断插件版本是否过旧 29 | * @returns {number} 30 | */ 31 | get version() { 32 | return EnhancePluginStore.version; 33 | } 34 | 35 | /** 36 | * 安装实例到窗口对象(仅插件调用) 37 | */ 38 | installToWindow() { 39 | Window.prototype.EnhancePluginStore_instance = this; 40 | } 41 | 42 | /** 43 | * @returns {(TimestampStyle.HMS|TimestampStyle.ONLY_SEC)} 44 | */ 45 | get timestampStyle() { 46 | return this.getValue(Settings.TS_URL_STYLE, TimestampStyle.HMS) || TimestampStyle.HMS; 47 | } 48 | 49 | /** 50 | * @param {(TimestampStyle.HMS|TimestampStyle.ONLY_SEC)} value 51 | */ 52 | set timestampStyle(value) { 53 | this.setValue(Settings.TS_URL_STYLE, value); 54 | } 55 | 56 | /** 57 | * @returns {number} 58 | */ 59 | get screenshotQuality() { 60 | return this.getValue(Settings.SCREENSHOT_QUALITY, 100); 61 | } 62 | 63 | /** 64 | * @param {number} value 65 | */ 66 | set screenshotQuality(value) { 67 | if (value < 50 || value > 100) { 68 | value = 100; 69 | } 70 | this.setValue(Settings.SCREENSHOT_QUALITY, value); 71 | } 72 | 73 | /** 74 | * @returns {('image/png'|'image/jpeg'|'image/webp')} 75 | */ 76 | get screenshotFormat() { 77 | return this.getValue(Settings.SCREENSHOT_FORMAT, 'image/png'); 78 | } 79 | 80 | /** 81 | * @param {('image/png'|'image/jpeg'|'image/webp')} value 82 | */ 83 | set screenshotFormat(value) { 84 | this.setValue(Settings.SCREENSHOT_FORMAT, value); 85 | } 86 | 87 | get recordMimeType() { 88 | return GM_getValue(Settings.RECORD_MIME_TYPE, 'default'); 89 | } 90 | 91 | set recordMimeType(value) { 92 | GM_setValue(Settings.RECORD_MIME_TYPE, value); 93 | } 94 | 95 | /** 96 | * @returns {boolean} value 97 | */ 98 | get cleanUrl() { 99 | return GM_getValue(Settings.CLEAN_URL, true); 100 | } 101 | 102 | /** 103 | * @param {boolean} value 104 | */ 105 | set cleanUrl(value) { 106 | GM_setValue(Settings.CLEAN_URL, value); 107 | } 108 | 109 | /** 110 | * @returns {boolean} value 111 | */ 112 | get timestampUseMicroseconds() { 113 | return GM_getValue(Settings.TS_USE_MICROSECONDS, false); 114 | } 115 | 116 | /** 117 | * @param {boolean} value 118 | */ 119 | set timestampUseMicroseconds(value) { 120 | GM_setValue(Settings.TS_USE_MICROSECONDS, value); 121 | } 122 | 123 | setValue(key, value) { 124 | GM_setValue(key, value); 125 | } 126 | 127 | getValue(key, defaultValue) { 128 | return GM_getValue(key, defaultValue); 129 | } 130 | 131 | addValueChangeListener(name, func) { 132 | GM_addValueChangeListener(name, func); 133 | } 134 | } 135 | 136 | export default EnhancePluginStore; 137 | -------------------------------------------------------------------------------- /src/util/recorder.js: -------------------------------------------------------------------------------- 1 | import {MimeTypes} from '../../common/constants'; 2 | 3 | /** 4 | * @enum {string} 5 | */ 6 | const MimeExtensions = { 7 | [MimeTypes.MP4]: 'mp4', 8 | [MimeTypes.MP3]: 'mp3', 9 | [MimeTypes.WEBM]: 'webm', 10 | [MimeTypes.MKV]: 'mkv', 11 | [MimeTypes.OGG]: 'ogg', 12 | [MimeTypes.WEBM_AUDIO]: 'webm', 13 | }; 14 | 15 | /** 16 | * @param {string?} mimeType 17 | * @returns {MimeExtensions} 18 | */ 19 | function findExtensionForMimeType(mimeType) { 20 | if (!mimeType) { 21 | return MimeExtensions[MimeTypes.MP4]; 22 | } 23 | for (const [key, value] of Object.entries(MimeExtensions)) { 24 | if (mimeType.indexOf(key) !== -1) { 25 | return value; 26 | } 27 | } 28 | } 29 | 30 | export class RecorderController { 31 | /** 32 | * 对 HTMLVideoElement 发起录制 33 | * @param {HTMLVideoElement} videoEl 34 | * @param {MimeTypes?} mimeType 35 | */ 36 | static captureVideoElement(videoEl, mimeType) { 37 | const controller = new RecorderController({ mimeType }); 38 | videoEl.captureStream = videoEl.captureStream || videoEl.mozCaptureStream; 39 | controller.start(videoEl.captureStream()); 40 | return controller; 41 | } 42 | 43 | /** 44 | * @constructor 45 | * @param {MimeTypes?} mimeType 46 | * @param {number} dataInterval 47 | */ 48 | constructor({ mimeType, dataInterval = 5000 } = {}) { 49 | this.recorder = null; 50 | this.recordedData = []; 51 | this.onrecordstop = null; 52 | this.onrecorderror = null; 53 | this.isRecording = false; 54 | 55 | this.mimeType = mimeType; 56 | this.dataInterval = dataInterval; 57 | } 58 | 59 | /** 60 | * 对 MediaStream 开始录制 61 | * @param {MediaStream} stream 62 | */ 63 | start(stream) { 64 | const recorder = new MediaRecorder(stream, { mimeType: this.mimeType }); 65 | const recordData = []; 66 | recorder.ondataavailable = (event) => { 67 | recordData.push(event.data); 68 | }; 69 | recorder.onstop = () => { 70 | this.isRecording = false; 71 | if (this.onrecordstop) { 72 | this.onrecordstop(); 73 | } 74 | }; 75 | recorder.onerror = (event) => { 76 | this.isRecording = false; 77 | if (this.onrecorderror) { 78 | this.onrecorderror(event); 79 | } 80 | }; 81 | recorder.start(this.dataInterval); 82 | this.isRecording = true; 83 | this.recorder = recorder; 84 | this.recordedData = recordData; 85 | } 86 | 87 | stop() { 88 | if (this.recorder && this.recorder.state === 'recording') { 89 | this.recorder.stop(); 90 | } 91 | } 92 | 93 | stopSync() { 94 | return new Promise((resolve) => { 95 | let old_onrecordstop = this.onrecordstop; 96 | this.onrecordstop = () => { 97 | if (old_onrecordstop) old_onrecordstop(); 98 | resolve(); 99 | }; 100 | this.stop(); 101 | }); 102 | } 103 | 104 | reset() { 105 | this.stop(); 106 | this.recorder = null; 107 | this.recordedData = []; 108 | this.isRecording = false; 109 | } 110 | 111 | /** 112 | * 保存录制结果 113 | * @param {string} filename 不包括扩展名的文件名 114 | */ 115 | save(filename) { 116 | if (!this.recordedData || this.recordedData.length <= 0) { 117 | return; 118 | } 119 | if (!filename) { 120 | window.console.log('Please specify a filename for downloading.'); 121 | filename = 'download'; 122 | } 123 | filename = filename + '.' + findExtensionForMimeType(this.recorder.mimeType); 124 | const a = document.createElement('a'); 125 | const objectURL = URL.createObjectURL(this.recordedBlob); 126 | a.download = filename; 127 | a.href = objectURL; 128 | a.click(); 129 | window.URL.revokeObjectURL(objectURL); 130 | } 131 | 132 | get recordedBlob() { 133 | return new Blob(this.recordedData, { type: this.recorder.mimeType }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/ui.js: -------------------------------------------------------------------------------- 1 | import {SELECTORS, IDS} from './constants'; 2 | 3 | // eslint-disable-next-line no-unused-vars 4 | class MenuActionItem { 5 | /** 6 | * @constructor 7 | * @param {({id: string, title: string, callback: Function})} data 数据 8 | */ 9 | constructor({id, title, callback}) { 10 | this.id = id; 11 | this.title = title; 12 | this.callback = callback; 13 | } 14 | } 15 | 16 | // eslint-disable-next-line no-unused-vars 17 | class EnhanceUIOptions { 18 | /** 19 | * @constructor 20 | * @param {({toastTimeout: number})} data 数据 21 | */ 22 | constructor({toastTimeout = 3000}) { 23 | this.toastTimeout = toastTimeout; 24 | } 25 | } 26 | 27 | /** 28 | * 懒加载 selector 指定的元素 29 | * @param {string|string[]} selector 选择器 30 | * @returns {Promise>} 31 | */ 32 | export function lazyElement(selector) { 33 | return new Promise((resolve) => { 34 | const callback = setInterval(() => { 35 | if (selector instanceof Array) { 36 | for (let item of selector) { 37 | const injectNode = $(item); 38 | if (injectNode.length) { 39 | clearInterval(callback); 40 | resolve(injectNode); 41 | break; 42 | } 43 | } 44 | } else { 45 | const injectNode = $(selector); 46 | if (injectNode.length) { 47 | clearInterval(callback); 48 | resolve(injectNode); 49 | } 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | export class EnhanceUIBase { 56 | /** 57 | * @constructor 58 | * @param {JQuery} player 59 | * @param {EnhanceUIOptions} options 60 | */ 61 | constructor(player, {toastTimeout = 3000} = {}) { 62 | this.player = player; 63 | this.menu = null; 64 | this.lastToastCallback = null; 65 | this.toastTimeout = toastTimeout; 66 | 67 | this._menuObserver = new MutationObserver(() => this.onMenuMutated(this.menu)); 68 | this._playerObserver = new MutationObserver(() => { 69 | const menu = player.find(SELECTORS.MENU); 70 | if (menu.length) { 71 | this.menu = menu; 72 | this._playerObserver.disconnect(); 73 | this._bindMenu(); 74 | } 75 | }); 76 | this._playerObserver.observe(player[0], { childList: true }); 77 | } 78 | 79 | /** 80 | * 隐藏 Toast 81 | */ 82 | hideToast() { 83 | const currentToast = $('#' + IDS.TOAST); 84 | if (currentToast.length) { 85 | currentToast.remove(); 86 | } 87 | if (this.lastToastCallback) { 88 | clearTimeout(this.lastToastCallback); 89 | } 90 | this.lastToastCallback = null; 91 | } 92 | 93 | /** 94 | * 显示文本为 message 的 Toast 95 | * @param {string} message 文本 96 | */ 97 | showToast(message) { 98 | const container = $(SELECTORS.TOAST_CONTAINER); 99 | if (container.length) { 100 | this.hideToast(); 101 | const toastItem = $(``); 102 | const toastText = $(''); 103 | toastText.append($('').text(message)); 104 | toastItem.append(toastText); 105 | container.append(toastItem); 106 | this.lastToastCallback = setTimeout(this.hideToast, this.toastTimeout); 107 | } else { 108 | // Fallback:当 Toast 版本已经改变时,使用普通的 alert 109 | alert(message); 110 | } 111 | } 112 | 113 | /** 114 | * 菜单变化事件 115 | * @param {JQuery} menu 菜单元素 116 | */ 117 | onMenuMutated(menu) { 118 | const ul = menu.find('ul'); 119 | if (menu.hasClass('active')) { 120 | // 不要注入弹幕菜单 121 | if (menu.find(SELECTORS.DANMAKU_CONTEXT_MENU).length) { 122 | return; 123 | } 124 | this.onInflateMenuActions().forEach((action) => { 125 | const actionEl = menu.find('#' + action.id); 126 | if (actionEl.length === 0) { 127 | ul.append(this._createMenuAction(action)); 128 | } 129 | }); 130 | const curActions = menu.find('li'); 131 | this.onInflateHiddenActions().forEach((text) => { 132 | $.each(curActions, (_index, action) => { 133 | if (action.innerText.indexOf(text) !== -1) { 134 | action.remove(); 135 | } 136 | }); 137 | }); 138 | } 139 | } 140 | 141 | /** 142 | * @return {Array} 要创建的菜单选项 143 | */ 144 | onInflateMenuActions() { 145 | throw new Error('onInflateMenuActions has no implementation.'); 146 | } 147 | 148 | /** 149 | * @returns {Array} 要隐藏的菜单关键词 150 | */ 151 | onInflateHiddenActions() { 152 | throw new Error('onInflateHiddenActions has no implementation.'); 153 | } 154 | 155 | destroy() { 156 | // noop in default implementation 157 | } 158 | 159 | /** 160 | * 开始监听菜单 161 | */ 162 | _bindMenu() { 163 | this._menuObserver.observe(this.menu[0], { childList: true, attributes: true }); 164 | this.onMenuMutated(this.menu); 165 | } 166 | 167 | /** 168 | * 创建菜单操作的元素 169 | * @param {MenuActionItem} action 菜单操作 170 | * @returns {JQuery} 171 | */ 172 | _createMenuAction({id, title, callback}) { 173 | const li = $(``); 174 | const a = $('').text(title); 175 | li.append(a); 176 | // 应用选项 Hover 样式 177 | li.mouseenter(() => li.addClass('hover')); 178 | li.mouseleave(() => li.removeClass('hover')); 179 | // 选项点击事件 180 | li.click(callback); 181 | return li; 182 | } 183 | } 184 | 185 | export class LiveEnhanceUIBase { 186 | /** 187 | * @constructor 188 | * @param {JQuery} player 189 | * @param {EnhanceUIOptions} options 190 | */ 191 | constructor(player, options) { 192 | this.player = player; 193 | this.menu = null; 194 | this.options = options; 195 | this.closeMenuA = null; 196 | 197 | this._playerObserver = new MutationObserver(() => { 198 | const menu = player.find(SELECTORS.LIVE_MENU); 199 | if (menu.length) { 200 | console.log(menu); 201 | this.menu = menu; 202 | this._playerObserver.disconnect(); 203 | this._bindMenu(); 204 | } 205 | }); 206 | this._playerObserver.observe(player[0], { childList: true }); 207 | } 208 | 209 | /** 210 | * 隐藏 Toast 211 | */ 212 | hideToast() { 213 | } 214 | 215 | /** 216 | * 显示文本为 message 的 Toast 217 | * @param {string} message 文本 218 | */ 219 | showToast(message) { 220 | window.alert(message); 221 | } 222 | 223 | /** 224 | * @return {Array} 要创建的菜单选项 225 | */ 226 | onInflateMenuActions() { 227 | throw new Error('onInflateMenuActions has no implementation.'); 228 | } 229 | 230 | /** 231 | * @returns {Array} 要隐藏的菜单关键词 232 | */ 233 | onInflateHiddenActions() { 234 | throw new Error('onInflateHiddenActions has no implementation.'); 235 | } 236 | 237 | destroy() { 238 | // noop in default implementation 239 | } 240 | 241 | /** 242 | * 开始监听菜单 243 | */ 244 | _bindMenu() { 245 | const ul = this.menu.find('ul').first(); 246 | const curActions = this.menu.find('li'); 247 | this.closeMenuA = curActions.find('a:contains(\'关闭\')'); 248 | this.onInflateMenuActions().forEach((action) => { 249 | const actionEl = this.menu.find('#' + action.id); 250 | if (actionEl.length === 0) { 251 | if (this.closeMenuA.length) { 252 | this._createMenuAction(action).insertBefore(this.closeMenuA.parent()); 253 | } else { 254 | ul.append(this._createMenuAction(action)); 255 | } 256 | } 257 | }); 258 | this.onInflateHiddenActions().forEach((text) => { 259 | $.each(curActions, (_index, action) => { 260 | if (action.innerText.indexOf(text) !== -1) { 261 | action.remove(); 262 | } 263 | }); 264 | }); 265 | } 266 | 267 | /** 268 | * 创建菜单操作的元素 269 | * @param {MenuActionItem} action 菜单操作 270 | * @returns {JQuery} 271 | */ 272 | _createMenuAction({id, title, callback}) { 273 | const li = $(``); 274 | const a = $('').text(title); 275 | li.append(a); 276 | // 选项点击事件 277 | li.click(() => { 278 | this.closeMenuA[0].click(); 279 | callback(); 280 | }); 281 | return li; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /pages/settings/components/BasicTabComponent.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 基本 4 | 5 | 6 | 7 | 视频菜单 8 | 9 | 10 | 11 | 12 | mdi-eye 13 | 14 | 原版菜单选项可见性 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | mdi-eye 39 | 40 | 增强菜单选项可见性 41 | 42 | 43 | 44 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 视频链接 68 | 69 | 70 | mdi-timer-10 71 | 72 | 73 | 时间格式(不影响跳转) 74 | 83 | 84 | 85 | 86 | 87 | mdi-spray-bottle 88 | 89 | 90 | 清爽的 URL 参数 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | mdi-clock-time-one-outline 99 | 100 | 101 | 精确到毫秒(不支持上述时间格式) 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 视频截图 113 | 114 | 115 | 116 | mdi-image 117 | 118 | 119 | 图像格式 120 | 128 | 129 | 130 | 131 | 132 | mdi-quality-high 133 | 134 | 135 | 质量 136 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 254 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { SELECTORS, IDS, TEXT, HIDDEN_KEYWORDS, LIVE_URL_PATTERN, USEFUL_VIDEO_URL_PARAMS } from './constants'; 2 | import { Settings, TimestampStyle } from '../common/constants'; 3 | import * as UI from './ui'; 4 | import * as Storage from './util/storage'; 5 | import * as Clipboard from './util/clipboard'; 6 | import {RecorderController} from './util/recorder'; 7 | import EnhancePluginStore from '../common/store'; 8 | 9 | const LOCALIZED = TEXT[Storage.getLanguage()]; 10 | let lastPlayerElement = null; 11 | /** 12 | * @type {EnhanceUIBase|LiveEnhanceUIBase} 13 | */ 14 | let ui = null; 15 | /** 16 | * @type {EnhancePluginStore} 17 | */ 18 | const store = new EnhancePluginStore(); 19 | /** 20 | * @type {RecorderController} 21 | */ 22 | let recorderController = null; 23 | 24 | class MainEnhanceUI extends UI.EnhanceUIBase { 25 | constructor(player, options) { 26 | super(player, options); 27 | } 28 | 29 | async copyUrlWithTimestamp() { 30 | let video = $('video'); 31 | if (video.length) { 32 | video = video[0]; 33 | const url = new URL(window.location.href); 34 | // 清理 Hash 35 | url.hash = ''; 36 | // 根据用户设定清理剩余的参数 37 | if (store.cleanUrl) { 38 | const leftParams = [...url.searchParams].filter(([key]) => USEFUL_VIDEO_URL_PARAMS.indexOf(key) !== -1); 39 | if (leftParams.length) { 40 | url.search = '?' + leftParams.map(([k, v]) => `${k}=${v}`) 41 | .reduce((prev, next) => `${prev}&${next}`); 42 | } else { 43 | url.search = ''; 44 | } 45 | } 46 | // 设定当前时间参数 47 | let tsText = ''; 48 | if (store.timestampUseMicroseconds) { 49 | const ms = Math.floor(video.currentTime * 1e3); 50 | url.searchParams.set('start_progress', String(ms)); 51 | tsText += ms + 'ms'; 52 | } else { 53 | const time = parseInt(video.currentTime); 54 | const h = parseInt(time / 60 / 60); 55 | const m = parseInt(time / 60 % 60); 56 | const s = parseInt(time % 60); 57 | if (store.timestampStyle === TimestampStyle.HMS) { 58 | let tsArg = ''; 59 | if (h > 0) tsArg += h + 'h'; 60 | if (m > 0) tsArg += m + 'm'; 61 | tsArg += s + 's'; 62 | url.searchParams.set('t', tsArg); 63 | } else { 64 | url.searchParams.set('t', time); 65 | } 66 | tsText += (h > 0 ? '' + h + ':' : '') + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s); 67 | } 68 | 69 | // 复制 URL 到剪贴板 70 | if (await Clipboard.copyText(url.toString())) { 71 | ui.showToast(LOCALIZED.TOAST_COPY_URL_WITH_TIMESTAMP_DONE(tsText)); 72 | } else { 73 | ui.showToast(LOCALIZED.TOAST_COPY_URL_FAILED); 74 | } 75 | } 76 | } 77 | 78 | async copyScreenshot() { 79 | let video = $('video'); 80 | if (video.length) { 81 | video = video[0]; 82 | if (video.readyState >= 2) { 83 | let canvas = document.createElement('canvas'); 84 | canvas.width = video.videoWidth; 85 | canvas.height = video.videoHeight; 86 | const ctx = canvas.getContext('2d'); 87 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 88 | const fmt = store.screenshotFormat; 89 | const quality = (fmt === 'image/png') ? 1 : (store.screenshotQuality / 100); 90 | try { 91 | await Clipboard.copyCanvasImage(canvas, fmt, quality); 92 | ui.showToast(LOCALIZED.TOAST_COPY_SCREENSHOT_DONE); 93 | } catch (ex) { 94 | window.console.error(ex); 95 | ui.showToast(LOCALIZED.TOAST_COPY_SCREENSHOT_FAILED); 96 | } 97 | canvas = null; 98 | } else { 99 | ui.showToast(LOCALIZED.TOAST_COPY_SCREENSHOT_NOT_READY); 100 | } 101 | } 102 | } 103 | 104 | async startRecord() { 105 | let mimeType = store.recordMimeType; 106 | if (mimeType === 'default') { 107 | mimeType = undefined; 108 | } 109 | window.console.log('Start record in mimeType=' + mimeType); 110 | try { 111 | recorderController = RecorderController.captureVideoElement($('video')[0], mimeType); 112 | recorderController.onrecordstop = () => window.console.log('record stop'); 113 | recorderController.onrecorderror = (event) => window.console.error(event); 114 | ui.showToast(LOCALIZED.TOAST_RECORD_STARTED); 115 | } catch (e) { 116 | window.console.error(e); 117 | ui.showToast(LOCALIZED.TOAST_RECORD_START_FAILED); 118 | } 119 | } 120 | 121 | async stopRecord() { 122 | if (recorderController && recorderController.isRecording) { 123 | await recorderController.stopSync(); 124 | const videoTitle = $(SELECTORS.VIDEO_TITLE).attr('title'); 125 | try { 126 | recorderController.save(`${videoTitle}_${bvid}_录制片段`); 127 | } catch (e) { 128 | window.console.log(e); 129 | } 130 | } 131 | } 132 | 133 | async destroy() { 134 | await this.stopRecord(); 135 | } 136 | 137 | onInflateMenuActions() { 138 | const menuActions = []; 139 | if (store.getValue(Settings.MENU_SHOW_COPY_TS_URL, 1) === 1) { 140 | menuActions.push({ 141 | id: IDS.MENU_COPY_TS_URL, 142 | title: LOCALIZED.ACTION_COPY_URL_WITH_TIMESTAMP, 143 | callback: this.copyUrlWithTimestamp 144 | }); 145 | } 146 | if (store.getValue(Settings.MENU_SHOW_COPY_SCREENSHOT, 1) === 1) { 147 | menuActions.push({ 148 | id: IDS.MENU_SCREENSHOT, 149 | title: LOCALIZED.ACTION_COPY_SCREENSHOT, 150 | callback: this.copyScreenshot 151 | }); 152 | } 153 | if (store.getValue(Settings.MENU_SHOW_RECORD, 1) === 1) { 154 | if (recorderController == null || !recorderController.isRecording) { 155 | menuActions.push({ 156 | id: IDS.MENU_RECORD, 157 | title: LOCALIZED.ACTION_RECORD_START, 158 | callback: this.startRecord 159 | }); 160 | } else { 161 | menuActions.push({ 162 | id: IDS.MENU_RECORD, 163 | title: LOCALIZED.ACTION_RECORD_STOP, 164 | callback: this.stopRecord 165 | }); 166 | } 167 | } 168 | menuActions.push({ 169 | id: IDS.MENU_SETTINGS, 170 | title: LOCALIZED.ACTION_SETTINGS, 171 | callback: () => { 172 | GM_openInTab('https://biliplayer.gwo.app', { active: true }); 173 | } 174 | }); 175 | return menuActions; 176 | } 177 | 178 | onInflateHiddenActions() { 179 | const hiddenActions = []; 180 | for (const [key, value] of Object.entries(HIDDEN_KEYWORDS)) { 181 | if (store.getValue(key, 1) !== 1) { 182 | hiddenActions.push(value); 183 | } 184 | } 185 | return hiddenActions; 186 | } 187 | } 188 | 189 | class LiveEnhanceUI extends UI.LiveEnhanceUIBase { 190 | constructor(player) { 191 | super(player, undefined); 192 | } 193 | 194 | async copyScreenshot() { 195 | let video = $('video'); 196 | if (video.length) { 197 | video = video[0]; 198 | if (video.readyState >= 2) { 199 | let canvas = document.createElement('canvas'); 200 | canvas.width = video.videoWidth; 201 | canvas.height = video.videoHeight; 202 | const ctx = canvas.getContext('2d'); 203 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 204 | const fmt = store.screenshotFormat; 205 | const quality = (fmt === 'image/png') ? 1 : (store.screenshotQuality / 100); 206 | try { 207 | await Clipboard.copyCanvasImage(canvas, fmt, quality); 208 | ui.showToast(LOCALIZED.TOAST_COPY_SCREENSHOT_DONE); 209 | } catch (ex) { 210 | window.console.error(ex); 211 | ui.showToast(LOCALIZED.TOAST_COPY_SCREENSHOT_FAILED); 212 | } 213 | canvas = null; 214 | } else { 215 | ui.showToast(LOCALIZED.TOAST_COPY_SCREENSHOT_NOT_READY); 216 | } 217 | } 218 | } 219 | 220 | async enterPiP() { 221 | let video = $('video'); 222 | if (video.length) { 223 | video = video[0]; 224 | video.requestPictureInPicture(); 225 | } 226 | } 227 | 228 | onInflateMenuActions() { 229 | const menuActions = []; 230 | if (store.getValue(Settings.MENU_SHOW_COPY_SCREENSHOT, 1) === 1) { 231 | menuActions.push({ 232 | id: IDS.MENU_SCREENSHOT, 233 | title: LOCALIZED.ACTION_COPY_SCREENSHOT, 234 | callback: this.copyScreenshot 235 | }); 236 | } 237 | if (store.getValue(Settings.LIVE_MENU_SHOW_PIP, 1) === 1) { 238 | menuActions.push({ 239 | id: IDS.MENU_PIP, 240 | title: LOCALIZED.ACTION_PIP, 241 | callback: this.enterPiP 242 | }); 243 | } 244 | return menuActions; 245 | } 246 | 247 | onInflateHiddenActions() { 248 | return []; 249 | } 250 | 251 | destroy() { 252 | 253 | } 254 | } 255 | 256 | async function enhanceMain() { 257 | const bindPlayer = async () => { 258 | const player = await UI.lazyElement(SELECTORS.PLAYER); 259 | if (lastPlayerElement === player) { 260 | console.log('isSameElement'); 261 | return; 262 | } 263 | if (ui !== null) { 264 | await ui.destroy(); 265 | } 266 | lastPlayerElement = player; 267 | ui = new MainEnhanceUI(player); 268 | }; 269 | 270 | // 自动下一分P播放器只改变 InnerContainer 中的元素,不会触发 PlayerWrapper 的 MutationObserver 回调 271 | const bindPlayerWrapper = async () => { 272 | const playerInnerContainer = await UI.lazyElement(SELECTORS.PLAYER_INNER_CONTAINER); 273 | 274 | const mutationObserver = new MutationObserver(bindPlayer); 275 | mutationObserver.observe(playerInnerContainer[0], { childList: true }); 276 | 277 | // 首次尝试绑定 278 | await bindPlayer(); 279 | }; 280 | 281 | const playerWrapper = await UI.lazyElement([SELECTORS.PLAYER_WRAPPER, SELECTORS.PLAYER_MODULE]); 282 | 283 | const mutationObserver = new MutationObserver(bindPlayerWrapper); 284 | mutationObserver.observe(playerWrapper[0], { childList: true }); 285 | 286 | // 首次尝试绑定 287 | await bindPlayer(); 288 | } 289 | 290 | async function enhanceLive() { 291 | const player = await UI.lazyElement(SELECTORS.LIVE_PLAYER); 292 | if (ui !== null) { 293 | await ui.destroy(); 294 | } 295 | ui = new LiveEnhanceUI(player); 296 | } 297 | 298 | // 增强插件主入口 299 | store.installToWindow(); 300 | if (LIVE_URL_PATTERN.test(window.location.href)) { 301 | console.log('enhance live'); 302 | enhanceLive(); 303 | } else { 304 | console.log('enhance main'); 305 | enhanceMain(); 306 | } 307 | Window.prototype.RecordController = RecorderController; 308 | --------------------------------------------------------------------------------
5 | 哔哩哔哩增强插件可以为你提供更好的网页端视频播放体验,在使用本设置之前你需要先安装最新的 6 | 插件本体。 7 |
9 | 如果插件在你的浏览器上不工作,可能的原因如下: 10 |
{{ currentRecordMimeType }}