├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml └── workflows │ ├── build-test.yml │ ├── partial-test.yml │ └── publish.yml ├── .gitignore ├── .prettierrc.mjs ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── ffmpeg │ ├── mt-core.js │ ├── mt-worker.js │ └── worker.js └── icon.png ├── docs ├── adapters.md ├── background.md ├── database.md ├── features.md ├── pages.md └── settings.md ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── adapters │ ├── capture-element.ts │ ├── index.ts │ └── websocket-hook.js ├── api │ ├── bilibili.ts │ ├── cloudflare.ts │ ├── github.ts │ └── vtb-moe.ts ├── background │ ├── context-menus │ │ ├── add-black-list.ts │ │ └── index.ts │ ├── forwards.ts │ ├── forwards │ │ ├── blive-data.ts │ │ ├── command.ts │ │ ├── danmaku.ts │ │ ├── jimaku.ts │ │ ├── redirect.ts │ │ ├── stream-content.ts │ │ └── summerize.ts │ ├── functions │ │ ├── boostWebsocketHook.ts │ │ ├── getBLiveCachedData.ts │ │ ├── getWindowVariable.ts │ │ ├── index.ts │ │ └── p2pLivePlayer.ts │ ├── index.ts │ ├── messages.ts │ ├── messages │ │ ├── add-black-list.ts │ │ ├── clear-table.ts │ │ ├── fetch-developer.ts │ │ ├── get-stream-urls.ts │ │ ├── hook-adapter.ts │ │ ├── inject-func.ts │ │ ├── inject-script.ts │ │ ├── migration-mv2.ts │ │ ├── notify.ts │ │ ├── open-options.ts │ │ ├── open-tab.ts │ │ ├── open-window.ts │ │ └── request.ts │ ├── ports.ts │ ├── scripts │ │ ├── clearIndexedDbTable │ │ │ ├── function.ts │ │ │ ├── index.ts │ │ │ └── script.ts │ │ └── index.ts │ └── update-listener.ts ├── components │ ├── BJFThemeProvider.tsx │ ├── BLiveThemeProvider.tsx │ ├── ChatBubble.tsx │ ├── ConditionalWrapper.tsx │ ├── DraggableFloatingButton.tsx │ ├── OfflineRecordsProvider.tsx │ ├── PromiseHandler.tsx │ ├── ShadowRoot.tsx │ ├── ShadowStyle.tsx │ ├── TailwindScope.tsx │ └── Tutorial.tsx ├── contents │ └── index │ │ ├── App.tsx │ │ ├── components │ │ ├── ButtonList.tsx │ │ ├── FloatingMenuButtion.tsx │ │ ├── Footer.tsx │ │ ├── FooterButton.tsx │ │ └── Header.tsx │ │ ├── index.tsx │ │ └── mounter.tsx ├── contexts │ ├── BLiveThemeDarkContext.ts │ ├── ContentContexts.ts │ ├── GenericContext.ts │ ├── JimakuFeatureContext.ts │ ├── RecorderFeatureContext.ts │ ├── ShadowRootContext.ts │ └── SuperChatFeatureContext.ts ├── database │ ├── index.ts │ ├── migrations.ts │ └── tables │ │ ├── jimaku.d.ts │ │ ├── stream.d.ts │ │ └── superchat.d.ts ├── features │ ├── index.ts │ ├── jimaku │ │ ├── components │ │ │ ├── ButtonArea.tsx │ │ │ ├── ButtonSwitchList.tsx │ │ │ ├── JimakuArea.tsx │ │ │ ├── JimakuAreaSkeleton.tsx │ │ │ ├── JimakuAreaSkeletonError.tsx │ │ │ ├── JimakuButton.tsx │ │ │ ├── JimakuCaptureLayer.tsx │ │ │ ├── JimakuLine.tsx │ │ │ ├── JimakuList.tsx │ │ │ └── JimakuVisibleButton.tsx │ │ └── index.tsx │ ├── recorder │ │ ├── components │ │ │ ├── ProgressText.tsx │ │ │ ├── RecorderButton.tsx │ │ │ └── RecorderLayer.tsx │ │ ├── index.tsx │ │ └── recorders │ │ │ ├── buffer.ts │ │ │ ├── capture.ts │ │ │ └── index.ts │ └── superchat │ │ ├── components │ │ ├── SuperChatArea.tsx │ │ ├── SuperChatButtonSkeleton.tsx │ │ ├── SuperChatCaptureLayer.tsx │ │ ├── SuperChatFloatingButton.tsx │ │ └── SuperChatItem.tsx │ │ └── index.tsx ├── ffmpeg │ ├── core-mt.ts │ ├── core.ts │ └── index.ts ├── hooks │ ├── bilibili.ts │ ├── binding.ts │ ├── dom.ts │ ├── ffmpeg.ts │ ├── force-update.ts │ ├── form.ts │ ├── forwarder.ts │ ├── life-cycle.ts │ ├── loader.ts │ ├── message.ts │ ├── optimizer.ts │ ├── promise.ts │ ├── records.ts │ ├── storage.ts │ ├── stream.ts │ ├── styles.ts │ ├── teleport.ts │ └── window.ts ├── llms │ ├── cloudflare-ai.ts │ ├── gemini-nano.ts │ ├── index.ts │ ├── models.ts │ ├── remote-worker.ts │ └── web-llm.ts ├── logger.ts ├── migrations │ ├── index.ts │ └── schema.ts ├── options │ ├── components │ │ ├── AffixInput.tsx │ │ ├── CheckBoxListItem.tsx │ │ ├── ColorInput.tsx │ │ ├── DataTable.tsx │ │ ├── DeleteIcon.tsx │ │ ├── Expander.tsx │ │ ├── ExperientmentFeatureIcon.tsx │ │ ├── FeatureRoomTable.tsx │ │ ├── Hints.tsx │ │ ├── HotKeyInput.tsx │ │ ├── Selector.tsx │ │ ├── SettingFragment.tsx │ │ └── SwitchListItem.tsx │ ├── features │ │ ├── index.ts │ │ ├── jimaku │ │ │ ├── components │ │ │ │ ├── AIFragment.tsx │ │ │ │ ├── ButtonFragment.tsx │ │ │ │ ├── DanmakuFragment.tsx │ │ │ │ ├── JimakuFragment.tsx │ │ │ │ └── ListingFragment.tsx │ │ │ └── index.tsx │ │ ├── recorder │ │ │ └── index.tsx │ │ └── superchat │ │ │ └── index.tsx │ ├── fragments.ts │ ├── fragments │ │ ├── capture.tsx │ │ ├── developer.tsx │ │ ├── display.tsx │ │ ├── features.tsx │ │ ├── listings.tsx │ │ ├── llm.tsx │ │ └── version.tsx │ ├── index.tsx │ └── shouldInit.ts ├── players │ ├── flv.ts │ ├── hls.ts │ └── index.ts ├── style.css ├── tabs │ ├── encoder.tsx │ ├── jimaku.tsx │ ├── stream.tsx │ └── summarizer.tsx ├── toaster.ts ├── types │ ├── bilibili │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── room-info.ts │ │ │ ├── room-init.ts │ │ │ ├── spec-area-rank.ts │ │ │ ├── stream-url.ts │ │ │ ├── superchat-list.ts │ │ │ ├── wbi-acc-info.ts │ │ │ └── web-interface-nav.ts │ │ ├── index.ts │ │ ├── live │ │ │ ├── danmu_msg.ts │ │ │ ├── index.ts │ │ │ ├── interact_word.ts │ │ │ └── super_chat_message.ts │ │ └── vtb-moe.ts │ ├── cloudflare │ │ ├── index.ts │ │ └── workers-ai.ts │ ├── common │ │ ├── index.ts │ │ ├── leaf.ts │ │ ├── react.ts │ │ └── schema.ts │ ├── extends │ │ ├── chrome-ai.d.ts │ │ ├── global.d.ts │ │ ├── index.d.ts │ │ └── react-tailwind.d.ts │ ├── github │ │ ├── index.ts │ │ └── release.ts │ └── media │ │ ├── index.ts │ │ ├── player.ts │ │ └── recorder.ts └── utils │ ├── bilibili.ts │ ├── binary.ts │ ├── database.ts │ ├── event.ts │ ├── fetch.ts │ ├── file.ts │ ├── func.ts │ ├── inject.ts │ ├── messaging.ts │ ├── misc.ts │ ├── react-node.ts │ ├── storage.ts │ └── subscriber.ts ├── tailwind.config.js ├── tests ├── content.spec.ts ├── features │ ├── jimaku.spec.ts │ ├── recorder.spec.ts │ └── superchat.spec.ts ├── fixtures │ ├── background.ts │ ├── base.ts │ ├── component.ts │ ├── content.ts │ └── extension.ts ├── helpers │ ├── bilibili-api.ts │ ├── bilibili-page.ts │ ├── listeners │ │ ├── dismiss-login-dialog-listener.ts │ │ └── type.ts │ ├── logger.ts │ ├── page-frame.ts │ └── room-finder.ts ├── integrations │ ├── recorder.spec.ts │ └── summarizer.spec.ts ├── modules │ ├── ffmpeg.js │ ├── llm.js │ ├── player.js │ ├── recorder.js │ └── utils.js ├── options.ts ├── pages │ ├── encoder.spec.ts │ └── options.spec.ts ├── theme.setup.ts ├── types │ └── movie.ts ├── units │ ├── bilibili-api.spec.ts │ ├── buffer.spec.ts │ ├── capture.spec.ts │ ├── ffmpeg.spec.ts │ └── llm.spec.ts └── utils │ ├── bilibili.ts │ ├── file.ts │ ├── misc.ts │ └── playwright.ts └── tsconfig.json /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 问题回报 2 | description: 创建一个问题回报 3 | title: "[Bug]: " 4 | labels: ["问题反映/修复", "求助"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: 感谢您抽出时间填写此问题回报! 9 | description: 在提交问题之前,请确保您已经搜索了现有的问题,以确保您的问题是独特的。 10 | options: 11 | - label: 我已经搜索了现有的问题,并且确认我的问题是独特的 12 | required: true 13 | - type: textarea 14 | id: problem 15 | attributes: 16 | label: 问题描述 17 | description: 清楚简明扼要地描述你所遇到的问题 18 | placeholder: 可以添加图片或其他文件作为补充 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: expected-result 23 | attributes: 24 | label: 期望结果 25 | description: 描述一下正常的时候理应出现的结果是如何? 26 | placeholder: 可以添加图片或其他文件作为补充 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: reproduce 31 | attributes: 32 | label: 复现步骤 33 | description: 请提供详细的复现步骤 34 | placeholder: | 35 | 1. 进入 '...' 36 | 2. 点进 '....' 37 | 3. 打开 '....' 38 | 4. 无法使用 39 | (可以添加图片或其他文件作为补充) 40 | validations: 41 | required: true 42 | - type: input 43 | id: browser 44 | attributes: 45 | label: 浏览器 46 | description: 你所使用的浏览器以及版本? 47 | placeholder: e.g. Chrome 版本 122.0.6261.113 (64 位元) 48 | validations: 49 | required: true 50 | - type: dropdown 51 | id: other-browsers 52 | attributes: 53 | label: 其他浏览器上的复现 54 | description: 除了你所使用的浏览器外,你还有在其他浏览器上复现了这个问题吗? 55 | multiple: true 56 | options: 57 | - Chrome 58 | - Microsoft Edge 59 | - Opera 60 | - Brave 61 | - type: input 62 | id: extension-version 63 | attributes: 64 | label: 扩展版本 65 | description: 你所使用的扩展版本? 66 | placeholder: e.g. 2.0.0 67 | validations: 68 | required: true 69 | - type: textarea 70 | id: logs 71 | attributes: 72 | label: 相关的日志(如有) 73 | description: 你可以打开 F12 的控制台查找相关的日志 74 | validations: 75 | required: false 76 | - type: textarea 77 | id: others 78 | attributes: 79 | label: 补充(如有) 80 | description: 你还有其他补充吗? 81 | validations: 82 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 其他讨论 4 | url: https://github.com/eric2788/bilibili-vup-stream-enhancer/discussions 5 | about: 如你的问题并非基于bug或功能请求,你可以在讨论区提出 6 | - name: 联络作者 7 | url: https://t.me/eric1008818 8 | about: 如果你认为现有资源无法解答你的问题,你可以直接联系作者 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: 创建一个功能请求 3 | title: "[功能请求]: <title>" 4 | labels: ["功能请求/增強"] 5 | body: 6 | - type: textarea 7 | id: feature 8 | attributes: 9 | label: 功能描述 10 | description: 请描述你所希望的功能是什么? 11 | placeholder: 可以添加图片或其他文件作为补充 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: why 16 | attributes: 17 | label: 为什么需要这个功能? 18 | description: 请描述为什么你需要这个功能? 19 | placeholder: | 20 | e.g. 我经常需要在 '...' 时做 '....',但是现在无法做到 21 | (可以添加图片或其他文件作为补充) 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: others 26 | attributes: 27 | label: 补充(如有) 28 | description: 你还有其他补充吗? 29 | validations: 30 | required: false -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Extensions 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | dry-run: 7 | description: 'dry run mode, will upload to the store but not publish and turn on verbose logging' 8 | required: false 9 | type: boolean 10 | default: true 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | browser: [chrome, edge] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: pnpm/action-setup@v3 21 | with: 22 | version: 9 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v4.0.1 25 | with: 26 | node-version: 20 27 | cache: 'pnpm' 28 | - name: Install dependencies 29 | run: pnpm install --frozen-lockfile 30 | - name: Build And Package Extensions 31 | run: pnpm build --zip --target=${{ matrix.browser }}-mv3 32 | - name: Upload to GitHub Artifacts 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: ${{ matrix.browser }}-mv3 36 | path: build/ 37 | publish: 38 | runs-on: ubuntu-latest 39 | needs: build 40 | steps: 41 | - name: Download Artifacts 42 | uses: actions/download-artifact@v4 43 | with: 44 | path: build/ 45 | - name: Browser Platform Publish 46 | uses: PlasmoHQ/bpp@v3.6.1 47 | with: 48 | verbose: ${{ inputs.dry-run }} 49 | keys: | 50 | { 51 | "$schema": "https://raw.githubusercontent.com/plasmo-corp/bpp/v3/keys.schema.json", 52 | "chrome": { 53 | "zip": "build/chrome-mv3/chrome-mv3-prod.zip", 54 | "clientId": "${{ secrets.CHROME_CLIENT_ID }}", 55 | "clientSecret": "${{ secrets.CHROME_CLIENT_SECRET }}", 56 | "refreshToken": "${{ secrets.CHROME_REFRESH_TOKEN }}", 57 | "extId": "nhomlepkjglilcahfcfnggebkaabeiog", 58 | "uploadOnly": ${{ inputs.dry-run }} 59 | }, 60 | "edge": { 61 | "zip": "build/edge-mv3/edge-mv3-prod.zip", 62 | "clientId": "${{ secrets.EDGE_CLIENT_ID }}", 63 | "clientSecret": "${{ secrets.EDGE_CLIENT_SECRET}}", 64 | "productId": "${{ secrets.EDGE_PRODUCT_ID }}", 65 | "accessTokenUrl": "${{ secrets.EDGE_ACCESS_TOKEN_URL }}", 66 | "uploadOnly": ${{ inputs.dry-run }}, 67 | "notes": "https://github.com/eric2788/bilibili-vup-stream-enhancer" 68 | } 69 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | *.cache.json 9 | 10 | # testing 11 | /coverage 12 | 13 | #cache 14 | .turbo 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | 23 | # local env files 24 | .env* 25 | .vscode/ 26 | .hintrc 27 | 28 | out/ 29 | build/ 30 | dist/ 31 | *.local.yml 32 | *-local.tsx 33 | *-local.ts 34 | 35 | # plasmo - https://www.plasmo.com 36 | .plasmo 37 | 38 | # bpp - http://bpp.browser.market/ 39 | keys.json 40 | 41 | # typescript 42 | .tsbuildinfo 43 | 44 | # PNPM related files and directories 45 | .pnpm 46 | .pnp.cjs 47 | .store 48 | /test-results/ 49 | /playwright-report/ 50 | /blob-report/ 51 | /playwright/.cache/ 52 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | export default { 5 | printWidth: 80, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: false, 9 | singleQuote: false, 10 | trailingComma: "none", 11 | bracketSpacing: true, 12 | bracketSameLine: true, 13 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 14 | importOrder: [ 15 | "<BUILTIN_MODULES>", // Node.js built-in modules 16 | "<THIRD_PARTY_MODULES>", // Imports not matched by other special words or groups. 17 | "", // Empty line 18 | "^@plasmo/(.*)$", 19 | "", 20 | "^@plasmohq/(.*)$", 21 | "", 22 | "^~(.*)$", 23 | "", 24 | "^[./]" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @eric2788 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Eric Lam 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. -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eric2788/bilibili-vup-stream-enhancer/9608c6d1b6b57272470bea4276007fac040f9ba6/assets/icon.png -------------------------------------------------------------------------------- /docs/adapters.md: -------------------------------------------------------------------------------- 1 | # 适配器 2 | 3 | 用于连接或转换不同的B站WS数据源以供内容脚本使用。 4 | 5 | ## 适配器列表 6 | 7 | 目前只有 `WebSocket` 和 `Dom` 两种适配器。 8 | 9 | WebSocket 是目前最常用的适配器,它直接挂钩网页上的 WebSocket 客户端,以获取B站WS数据。 10 | 11 | Dom 适配器则是通过解析网页上的DOM元素,以获取特定数据。 12 | 13 | WebSocket 相比Dom适配器更加稳定和更加泛用。但万一 WebSocket 无法使用,Dom 适配器则是一个备选方案。 14 | 15 | 如果你需要新增一个适配器,你需要留意以下内容: 16 | 17 | - 你需要用到 [`utils/messaging.ts`](/src/utils/messaging.ts) 中的 `sendBLiveMessage` 方法,以发送数据到内容脚本。 18 | 19 | - 新增方式可以在 `adapters/` 目录下创建文件,然后在 `adapters/index.ts` 中进行注册。 20 | 21 | - 基于目前以WS为主的情况,你需要发送以 WS 结构为準的数据。 22 | -------------------------------------------------------------------------------- /docs/database.md: -------------------------------------------------------------------------------- 1 | # 数据库 2 | 3 | 本扩展的数据库采用 IndexedDB 来存储数据。IndexedDB 是一个浏览器端的数据库,它提供了一个对象存储的解决方案,可以存储大量的结构化数据。 4 | 5 | > 关于 IndexedDB,本扩展使用了 [Dexie.js](https://dexie.org/) 这个库来进行 IndexedDB 的操作。 6 | 7 | ## 新增新的数据库 8 | 9 | 在 `src/database/tables` 目录下新增一个新的数据库,例如 `src/database/person.d.ts`。 10 | 11 | ```ts 12 | import { Table } from 'dexie' 13 | import { CommonSchema } from '~database' 14 | 15 | declare module '~database' { 16 | interface IndexedDatabase { 17 | persons: Table<Person, number> 18 | } 19 | } 20 | 21 | interface Person extends CommonSchema { 22 | name: string 23 | age: number 24 | nickname?: string 25 | } 26 | ``` 27 | 28 | 完成后,你需要到 `src/database/migration.ts` 建立新版本迁移,然后添加你的数据库创建。 29 | 30 | ```ts 31 | import type Dexie from "dexie" 32 | import { commonSchema } from '~database' 33 | 34 | export default function (db: Dexie) { 35 | // version 1 36 | db.version(1).stores({ 37 | superchats: commonSchema + "text, scId, backgroundColor, backgroundImage, backgroundHeaderColor, userIcon, nameColor, uid, uname, price, message, hash, timestamp", 38 | jimakus: commonSchema + "text, uid, uname, hash" 39 | }) 40 | 41 | db.version(2).stores({ 42 | persons: commonSchema + "name, age, nickname" 43 | }) 44 | } 45 | ``` 46 | 47 | > 关于 Migrate 使用方式,请参考 [Dexie.js 的文档](https://dexie.org/docs/Tutorial/Understanding-the-basics#changing-a-few-tables-only) 48 | 49 | ## 使用 50 | 51 | 完成后,使用只需要使用 `src/database` 的 `db` 即可。 52 | 53 | ```ts 54 | import { db } from '~database' 55 | 56 | // 执行数据库操作 57 | async function addPerson(){ 58 | await db.persons.add({ name: 'world', age: 99 }) 59 | } 60 | 61 | ``` 62 | 63 | > 有关使用方式,还是请参考 [Dexie.js 的文档](https://dexie.org/docs/Collection/Collection) 64 | 65 | -------------------------------------------------------------------------------- /docs/pages.md: -------------------------------------------------------------------------------- 1 | # 新增页面 2 | 3 | > 本扩展基于 [Plasmo Extension Page](https://docs.plasmo.com/framework/ext-pages) 进行页面渲染。 4 | 5 | 如要新增页面,只需要到以下地方新增即可: 6 | 7 | ``` 8 | src/ 9 | tabs/ <- 扩展页面 10 | ``` 11 | 12 | ## 创建一个新的扩展页面 13 | 14 | 在 `src/tabs/` 目录下新增一个新的扩展页面,例如 `src/tabs/hello-world.tsx`。 15 | 16 | ```tsx 17 | 18 | import '~style.css' // 汇入 tailwindcss 样式以在页面中使用 19 | 20 | function App(): JSX.Element { 21 | return ( 22 | <div>Hello, World!</div> 23 | ) 24 | } 25 | 26 | export default App 27 | ``` 28 | 29 | 完成后,扩展在建置时会自动生成 hello-world.html 文件,且会自动添加到扩展的 `manifest.json` 中。 30 | 31 | ## 跳转到扩展页面 32 | 33 | 如要在其他位置跳转到扩展页面,有以下两种方式: 34 | 35 | - 使用 `open-tab` messager 指令,进行跳转: 36 | 37 | 范例如下: 38 | 39 | ```tsx 40 | function HelloWorldButton(): JSX.Element { 41 | const openPage = () => sendMessager('open-tab', { tab: 'hello-world' }) 42 | return <button onClick={openPage}>click me to open!</button> 43 | } 44 | 45 | export default HelloWorldButton 46 | ``` 47 | 48 | - 使用 `chrome.tabs.create` 和 `chrome.runtime.getURL` API,进行跳转: 49 | 50 | 范例如下: 51 | 52 | ```tsx 53 | function HelloWorldButton(): JSX.Element { 54 | const openPage = () => chrome.tabs.create({ url: chrome.runtime.getURL('/tabs/hello-world.html') }) 55 | return <button onClick={openPage}>click me to open!</button> 56 | } 57 | export default HelloWorldButton 58 | ``` 59 | 60 | > 使用 chrome API 方式可能需要考虑到一些API无法在内容脚本中使用的问题,因此建议使用 messager 方式进行跳转。 61 | 62 | 有关 messager 的更多信息,你可以参阅 [`docs/background.md`](./background.md) 文档中有关 messager 的信息。 63 | 64 | 65 | ## 进阶开发 66 | 67 | 你可以参考以下的源码以进行更进阶页面开发: 68 | 69 | - [现成的扩展页面参考](/src/tabs/) 70 | - [自定义Hooks](/src/hooks/) 71 | - [辅助函数](/src/utils/) 72 | - [全局组件](/src/components) -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('autoprefixer'.ProcessOptions)} 3 | * @type {import('postcss').ProcessOptions} 4 | */ 5 | module.exports = { 6 | plugins: { 7 | tailwindcss: {}, 8 | autoprefixer: {}, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import dom from 'url:~adapters/capture-element.ts' 2 | import websocket from 'url:~adapters/websocket-hook.js' 3 | 4 | export type Adapters = typeof adapters 5 | export type AdapterType = keyof Adapters 6 | 7 | export const adapters = { 8 | dom, 9 | websocket 10 | } -------------------------------------------------------------------------------- /src/api/cloudflare.ts: -------------------------------------------------------------------------------- 1 | import type { AIResponse, Result } from "~types/cloudflare"; 2 | import { parseSSEResponses } from "~utils/binary"; 3 | 4 | const BASE_URL = 'https://api.cloudflare.com/client/v4' 5 | 6 | export async function runAI(data: any, { token, account, model }: { token: string, account: string, model: string }): Promise<Result<AIResponse>> { 7 | const res = await fetch(`${BASE_URL}/accounts/${account}/ai/run/${model}`, { 8 | method: 'POST', 9 | headers: { 10 | Authorization: `Bearer ${token}` 11 | }, 12 | body: JSON.stringify({ ...data, stream: false }) 13 | }) 14 | const json = await res.json() as Result<AIResponse> 15 | if (!res.ok) throw new Error(json.errors.join('\n')) 16 | return json 17 | } 18 | 19 | export async function* runAIStream(data: any, { token, account, model }: { token: string, account: string, model: string }): AsyncGenerator<string> { 20 | const res = await fetch(`${BASE_URL}/accounts/${account}/ai/run/${model}`, { 21 | method: 'POST', 22 | headers: { 23 | Authorization: `Bearer ${token}` 24 | }, 25 | body: JSON.stringify({ ...data, stream: true }) 26 | }) 27 | if (!res.ok) { 28 | const json = await res.json() as Result<AIResponse> 29 | throw new Error(json.errors.join('\n')) 30 | } 31 | if (!res.body) throw new Error('Cloudflare AI response body is not readable') 32 | const reader = res.body.getReader() 33 | for await (const response of parseSSEResponses(reader, '[DONE]')) { 34 | yield response 35 | } 36 | } 37 | 38 | export async function validateAIToken(accountId: string, token: string, model: string): Promise<string | boolean> { 39 | const res = await fetch(`${BASE_URL}/accounts/${accountId}/ai/models/search?search=${model}&per_page=1`, { 40 | headers: { 41 | Authorization: `Bearer ${token}` 42 | } 43 | }) 44 | const data = await res.json() as Result<any> 45 | if (!data.success) { 46 | return false 47 | } else if (data.result.length === 0) { 48 | return '找不到指定 AI 模型' 49 | } else { 50 | return true 51 | } 52 | } -------------------------------------------------------------------------------- /src/api/github.ts: -------------------------------------------------------------------------------- 1 | import type { ReleaseInfo } from "~types/github"; 2 | import { fetchAndCache } from "~utils/fetch"; 3 | 4 | export async function getLatestRelease(): Promise<ReleaseInfo> { 5 | return await fetchAndCache<ReleaseInfo>('https://api.github.com/repos/eric2788/bilibili-vup-stream-enhancer/releases/latest') 6 | } 7 | 8 | export async function getRelease(tag: string): Promise<ReleaseInfo> { 9 | return await fetchAndCache<ReleaseInfo>(`https://api.github.com/repos/eric2788/bilibili-vup-stream-enhancer/releases/tags/${tag}`) 10 | } -------------------------------------------------------------------------------- /src/api/vtb-moe.ts: -------------------------------------------------------------------------------- 1 | import type { VtbMoeDetailResponse, VtbMoeListResponse } from "~types/bilibili" 2 | import { sendRequest } from "~utils/fetch" 3 | 4 | export async function getVupDetail(uid: string): Promise<VtbMoeDetailResponse | undefined> { 5 | try { 6 | return await sendRequest<VtbMoeDetailResponse>({ 7 | url: `https://api.vtbs.moe/v1/detail/${uid}`, 8 | timeout: 5000 9 | }) 10 | } catch (err: Error | any) { 11 | console.warn(err) 12 | return undefined 13 | } 14 | } 15 | 16 | export type VupResponse = { 17 | id: string 18 | name: string 19 | locale: string 20 | } 21 | 22 | export async function listAllVupUids(): Promise<VupResponse[]> { 23 | const res = await sendRequest<VtbMoeListResponse>({ 24 | url: 'https://vdb.vtbs.moe/json/list.json', 25 | timeout: 5000 26 | }) 27 | return res.vtbs.filter(v => v.type === 'vtuber').map(v => { 28 | const acc = v.accounts.find(acc => acc.platform == 'bilibili') 29 | return acc ? { 30 | id: acc.id, 31 | name: v.name[v.name.default], 32 | locale: v.name.default 33 | } : undefined 34 | }).filter(v => !!v) 35 | } 36 | 37 | export async function identifyVup(uid: string | number): Promise<VupResponse | undefined> { 38 | return (await listAllVupUids()).find(v => v.id === uid.toString()) 39 | } -------------------------------------------------------------------------------- /src/background/context-menus/add-black-list.ts: -------------------------------------------------------------------------------- 1 | import { sendInternal } from '~background/messages' 2 | import { getRoomId } from '~utils/bilibili' 3 | 4 | export const properties: chrome.contextMenus.CreateProperties = { 5 | id: 'add-black-list', 6 | title: '添加到黑名单', 7 | documentUrlPatterns: ['https://live.bilibili.com/*'], 8 | contexts: ['page'], 9 | enabled: true 10 | } 11 | 12 | 13 | export default async function (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab): Promise<void> { 14 | const url = new URL(info.pageUrl) 15 | const roomId = getRoomId(url.pathname) 16 | 17 | if (!roomId) { 18 | console.warn(`unknown room id (${url.pathname})`) 19 | return await sendInternal('notify', { 20 | title: '添加失败', 21 | message: `未知的直播间: ${url.pathname}` 22 | }) 23 | } 24 | 25 | await sendInternal('add-black-list', { roomId, sourceUrl: tab.url }) 26 | 27 | } -------------------------------------------------------------------------------- /src/background/context-menus/index.ts: -------------------------------------------------------------------------------- 1 | import * as blacklist from './add-black-list' 2 | 3 | const { contextMenus } = chrome 4 | 5 | const menus = [ 6 | blacklist 7 | ] 8 | 9 | const rClickMap: { 10 | [index: string]: (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) => Promise<void> 11 | } = {} 12 | 13 | 14 | chrome.runtime.onInstalled.addListener(() => { 15 | for (const menu of menus) { 16 | const { properties, default: consume } = menu 17 | rClickMap[properties.id] = consume 18 | contextMenus.create(properties) 19 | }}) 20 | 21 | contextMenus.onClicked.addListener((info, tab) => { 22 | const consume = rClickMap[info.menuItemId] 23 | if (!consume) return 24 | consume(info, tab).catch((error: Error) => { 25 | console.error('右鍵事件時出現錯誤: ', error.message ?? error) 26 | console.error(error) 27 | }) 28 | }) 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/background/forwards/blive-data.ts: -------------------------------------------------------------------------------- 1 | import type { BLiveDataWild } from "~types/bilibili" 2 | import { useDefaultHandler } from "~background/forwards" 3 | 4 | export type ForwardBody<K extends string> = { 5 | cmd: K 6 | command: BLiveDataWild<K> 7 | } 8 | 9 | export default useDefaultHandler<ForwardBody<any>>() -------------------------------------------------------------------------------- /src/background/forwards/command.ts: -------------------------------------------------------------------------------- 1 | import { useDefaultHandler } from '~background/forwards' 2 | 3 | export type ForwardBody = { 4 | command: string 5 | body?: any 6 | } 7 | 8 | 9 | export default useDefaultHandler<ForwardBody>() -------------------------------------------------------------------------------- /src/background/forwards/danmaku.ts: -------------------------------------------------------------------------------- 1 | 2 | import { type ForwardHandler } from "../forwards" 3 | 4 | 5 | export type ResponseBody = { 6 | uname: string 7 | text: string 8 | color: string 9 | pos: 'scroll' | 'top' | 'bottom' 10 | room: string 11 | } 12 | 13 | 14 | export type ForwardBody = { 15 | uname: string 16 | text: string 17 | color: number 18 | position: number 19 | room: string 20 | } 21 | 22 | const handler: ForwardHandler<ForwardBody, ResponseBody> = (req) => { 23 | 24 | let pos: 'scroll' | 'top' | 'bottom' = 'scroll' 25 | switch (req.body.position) { 26 | case 5: 27 | pos = 'top' 28 | break 29 | case 4: 30 | pos = 'bottom' 31 | break 32 | } 33 | 34 | return { 35 | ...req, 36 | body: { 37 | room: req.body.room, 38 | uname: req.body.uname, 39 | text: req.body.text, 40 | color: `#${req.body.color.toString(16)}`, 41 | pos, 42 | } 43 | } 44 | } 45 | 46 | export default handler -------------------------------------------------------------------------------- /src/background/forwards/jimaku.ts: -------------------------------------------------------------------------------- 1 | import { md5 } from 'hash-wasm' 2 | 3 | import type { ForwardHandler } from "../forwards" 4 | 5 | export type ForwardBody = { 6 | room: string 7 | text: string 8 | date: string 9 | } 10 | 11 | 12 | export type ForwardResponse = ForwardBody & { 13 | hash: string 14 | } 15 | 16 | 17 | // this handler is just for adding hash to the body 18 | const handler: ForwardHandler<ForwardBody, ForwardResponse> = async (req) => { 19 | const hash = await md5(JSON.stringify(req.body)) 20 | return { 21 | ...req, 22 | body: { 23 | ...req.body, 24 | hash 25 | } 26 | } 27 | } 28 | 29 | export default handler -------------------------------------------------------------------------------- /src/background/forwards/redirect.ts: -------------------------------------------------------------------------------- 1 | import { type ForwardInfo, useDefaultHandler } from '~background/forwards' 2 | 3 | export type ForwardBody = ForwardInfo<any> & { queryInfo?: Partial<chrome.tabs.QueryInfo> } 4 | 5 | export default useDefaultHandler<ForwardBody>() -------------------------------------------------------------------------------- /src/background/forwards/stream-content.ts: -------------------------------------------------------------------------------- 1 | import { useDefaultHandler } from "~background/forwards" 2 | import type { VideoInfo } from "~players" 3 | 4 | export type ForwardBody = { 5 | stage: 'init' 6 | id: string 7 | duration: number 8 | videoInfo: VideoInfo 9 | filename: string 10 | totalChunks: number 11 | } | { 12 | stage: 'data' 13 | id: string 14 | order: number 15 | content: string 16 | } | { 17 | stage: 'end' 18 | id: string 19 | } | { 20 | stage: 'ready' 21 | id: string 22 | } | { 23 | stage: 'error' 24 | message: string 25 | id: string 26 | } 27 | 28 | export default useDefaultHandler<ForwardBody>() -------------------------------------------------------------------------------- /src/background/forwards/summerize.ts: -------------------------------------------------------------------------------- 1 | import { useDefaultHandler } from "~background/forwards" 2 | 3 | export type ForwardBody = { 4 | roomId: string 5 | jimakus: string[] 6 | } 7 | 8 | export default useDefaultHandler<ForwardBody>() -------------------------------------------------------------------------------- /src/background/functions/boostWebsocketHook.ts: -------------------------------------------------------------------------------- 1 | 2 | async function boostWebSocketHook(): Promise<void> { 3 | // prevent duplicate injection 4 | if (WebSocket.prototype._send) return 5 | return new Promise((res, rej) => { 6 | // this change added a fast hook for websocket onmessage, but will not change the original onmessage functionality 7 | WebSocket.prototype.onInterceptMessage = function (msg, realOnMessage) { 8 | realOnMessage(msg) 9 | } 10 | WebSocket.prototype._send = WebSocket.prototype.send 11 | WebSocket.prototype.send = function (data) { 12 | this._send(data); 13 | const onmsg = this.onmessage 14 | if (onmsg instanceof Function) { 15 | this.onmessage = function (event: MessageEvent) { 16 | this.onInterceptMessage(event, onmsg) 17 | } 18 | console.log('websocket hook boosted.') 19 | } else { 20 | console.warn('cannot boost websocket hook, onmessage is not a function.') 21 | rej('cannot boost websocket hook, onmessage is not a function.') 22 | } 23 | this.send = this._send 24 | res() 25 | } 26 | }) 27 | } 28 | 29 | export default boostWebSocketHook -------------------------------------------------------------------------------- /src/background/functions/getBLiveCachedData.ts: -------------------------------------------------------------------------------- 1 | import type { GetInfoByRoomResponse, RoomInitResponse, V1Response } from "~types/bilibili" 2 | 3 | // window.__NEPTUNE_IS_MY_WAIFU__ 4 | export type NeptuneIsMyWaifu = { 5 | 'roomInfoRes': V1Response<GetInfoByRoomResponse> 6 | 'roomInitRes': V1Response<RoomInitResponse> 7 | } 8 | 9 | export function getBLiveCachedData<K extends keyof NeptuneIsMyWaifu>(key: K): NeptuneIsMyWaifu[K] { 10 | return window['__NEPTUNE_IS_MY_WAIFU__']?.[key] as NeptuneIsMyWaifu[K] 11 | } 12 | 13 | 14 | export default getBLiveCachedData -------------------------------------------------------------------------------- /src/background/functions/getWindowVariable.ts: -------------------------------------------------------------------------------- 1 | 2 | function getWindowVariable(key: string): any { 3 | const nested = key.split('.') 4 | if (nested.length === 1) return window[key] 5 | let current = window 6 | for (const k of nested) { 7 | current = current[k] 8 | } 9 | return current 10 | } 11 | 12 | export default getWindowVariable -------------------------------------------------------------------------------- /src/background/functions/index.ts: -------------------------------------------------------------------------------- 1 | import boostWebSocketHook from './boostWebsocketHook' 2 | import getBLiveCachedData from './getBLiveCachedData' 3 | import getWindowVariable from './getWindowVariable' 4 | import invokeLivePlayer from "./p2pLivePlayer" 5 | 6 | export interface InjectableFunction<T extends InjectableFunctionType> { 7 | name: T 8 | args: InjectableFunctionParameters<T> 9 | } 10 | 11 | export type InjectableFunctions = typeof functions 12 | 13 | export type InjectableFunctionType = keyof InjectableFunctions 14 | 15 | export type InjectableFunctionParameters<T extends InjectableFunctionType> = Parameters<InjectableFunctions[T]> 16 | 17 | export type InjectableFunctionReturnType<T extends InjectableFunctionType> = ReturnType<InjectableFunctions[T]> 18 | 19 | const functions = { 20 | getWindowVariable, 21 | getBLiveCachedData, 22 | boostWebSocketHook, 23 | invokeLivePlayer 24 | } 25 | 26 | 27 | export default functions 28 | 29 | -------------------------------------------------------------------------------- /src/background/functions/p2pLivePlayer.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | function invokeLivePlayer(name: string, ...args: any[]): any { 4 | const self = window as any 5 | if (!self.$P2PLivePlayer) { 6 | console.warn('P2PLivePlayer not found') 7 | return undefined 8 | } 9 | return self.$P2PLivePlayer[name](...args) 10 | } 11 | 12 | 13 | export default invokeLivePlayer -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import './context-menus' 2 | import './update-listener' 3 | 4 | import { getForwarder, sendForward } from './forwards' 5 | 6 | 7 | // browser extension icon click listener 8 | chrome.action.onClicked.addListener(() => { 9 | chrome.runtime.openOptionsPage() 10 | }) 11 | 12 | chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }) 13 | 14 | getForwarder('redirect', 'background').addHandler(data => { 15 | console.info('received redirect: ', data) 16 | sendForward(data.target, data.command, data.body, data.queryInfo ?? { active: true }) 17 | }) -------------------------------------------------------------------------------- /src/background/messages/add-black-list.ts: -------------------------------------------------------------------------------- 1 | import { sendForward } from '~background/forwards' 2 | import { sendInternal } from '~background/messages' 3 | import { getSettingStorage, setSettingStorage } from '~utils/storage' 4 | 5 | 6 | import type { PlasmoMessaging } from "@plasmohq/messaging" 7 | export type RequestBody = { 8 | roomId: string, 9 | sourceUrl?: string 10 | } 11 | 12 | 13 | 14 | const handler: PlasmoMessaging.MessageHandler<RequestBody> = async (req, res) => { 15 | 16 | const { roomId, sourceUrl } = req.body 17 | 18 | const settings = await getSettingStorage('settings.listings') 19 | 20 | if (settings.blackListRooms.map(r => r.room).includes(roomId)) { 21 | return sendInternal('notify', { 22 | title: '你已添加过此房间到黑名单。', 23 | message: '已略过操作。' 24 | }) 25 | } 26 | 27 | settings.blackListRooms.push({ 28 | room: roomId, 29 | addedDate: new Date().toLocaleDateString() 30 | }) 31 | 32 | await setSettingStorage('settings.listings', settings) 33 | await sendInternal('notify', { 34 | title: '已添加到黑名单', 35 | message: `房间 ${roomId} 已添加到黑名单。` 36 | }) 37 | 38 | const url = sourceUrl ?? req?.sender?.tab?.url ?? '*://live.bilibili.com/*' 39 | sendForward('content-script', 'command', { command: 'stop' }, { url }) 40 | } 41 | 42 | export default handler -------------------------------------------------------------------------------- /src/background/messages/clear-table.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { sendInternal } from '~background/messages' 3 | import { type TableType } from '~database' 4 | import { InjectScript } from '~utils/inject' 5 | 6 | export type RequestBody = { 7 | table: TableType | 'all' 8 | room?: string 9 | } 10 | 11 | export type ResponseBody = { 12 | result: 'success' | 'fail' | 'tab-not-closed', 13 | error?: string 14 | } 15 | 16 | const handler: PlasmoMessaging.MessageHandler<RequestBody, ResponseBody> = async (req, res) => { 17 | try { 18 | const tabs = await chrome.tabs.query({ url: '*://live.bilibili.com/*' }) 19 | if (tabs.length > 0) { 20 | res.send({ result: 'tab-not-closed', error: '检测到你有直播房间分页未关闭,请先关闭所有直播房间分页' }) 21 | return 22 | } 23 | const tab = await chrome.tabs.create({ 24 | active: false, 25 | url: 'https://live.bilibili.com' 26 | }) 27 | const result = await sendInternal('inject-script', { 28 | target: { tabId: tab.id }, 29 | script: new InjectScript('clearIndexedDbTable', req.body.table, req.body.room ?? '') 30 | }) 31 | await chrome.tabs.remove(tab.id) 32 | if (result.error) throw new Error(result.error) 33 | res.send({ result: 'success' }) 34 | } catch (err: Error | any) { 35 | console.error(err) 36 | res.send({ result: 'fail', error: err?.message ?? err }) 37 | } 38 | } 39 | 40 | 41 | export default handler -------------------------------------------------------------------------------- /src/background/messages/fetch-developer.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { sendInternal } from '~background/messages' 3 | import type { SettingSchema as DeveloperSchema } from "~options/fragments/developer" 4 | 5 | const developerLink = `https://cdn.jsdelivr.net/gh/eric2788/bilibili-vup-stream-enhancer@web/cdn/developer_v2.json` 6 | 7 | export type ResponseBody = { 8 | data?: DeveloperSchema, 9 | error?: string 10 | } 11 | 12 | const handler: PlasmoMessaging.MessageHandler<{}, ResponseBody> = async (req, res) => { 13 | const { data, error } = await sendInternal('request', { url: developerLink }) 14 | res.send({ data: data?.developer, error }) 15 | } 16 | 17 | export default handler -------------------------------------------------------------------------------- /src/background/messages/hook-adapter.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import type { AdapterType } from "~adapters" 3 | import { adapters } from '~adapters' 4 | import { sendInternal } from '~background/messages' 5 | import { getResourceName } from '~utils/file' 6 | 7 | import type { Settings } from "~options/fragments" 8 | import type { FuncEventResult } from "~utils/event" 9 | export type AdaptOperation = 'hook' | 'unhook' 10 | 11 | type HookBody = { 12 | command: 'hook' 13 | type: AdapterType 14 | settings: Settings 15 | } 16 | 17 | type OtherBody = { 18 | command: AdaptOperation 19 | } 20 | 21 | export type RequestBody = HookBody | OtherBody 22 | 23 | export type ResponseBody = FuncEventResult & { result?: any } 24 | 25 | const handler: PlasmoMessaging.MessageHandler<RequestBody, ResponseBody> = async (req, res) => { 26 | 27 | const { command } = req.body 28 | 29 | let result: ResponseBody = { success: true } 30 | 31 | if (command === 'hook') { 32 | const { type, settings } = req.body as HookBody 33 | const file = getResourceName(adapters[type]) 34 | console.info('injecting adapter: ', file) 35 | const res = await sendInternal('inject-script', { 36 | target: { 37 | tabId: req.sender.tab.id, 38 | frameIds: [req.sender.frameId] // for theme room 39 | }, 40 | fileUrl: adapters[type], 41 | func: command, 42 | args: [settings] 43 | }, req.sender) 44 | if (res) { 45 | result = res 46 | } 47 | } else { 48 | console.info('unhooking adapter') 49 | const res = await sendInternal('inject-script', { 50 | target: { 51 | tabId: req.sender.tab.id, 52 | frameIds: [req.sender.frameId] // for theme room 53 | }, 54 | func: command, 55 | }, req.sender) 56 | if (res) { 57 | result = res 58 | } 59 | } 60 | 61 | res.send(result) 62 | } 63 | 64 | 65 | export default handler -------------------------------------------------------------------------------- /src/background/messages/inject-func.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import type { InjectableFunction } from "~background/functions" 3 | import functions from '~background/functions' 4 | 5 | export type RequestBody = Omit<Partial<chrome.scripting.ScriptInjection<any[], any>>, 'func' | 'args'> & { 6 | function: InjectableFunction<any> 7 | } 8 | 9 | export type ResponseBody = chrome.scripting.InjectionResult<any>[] 10 | 11 | const handler: PlasmoMessaging.MessageHandler<RequestBody, ResponseBody> = async (req, res) => { 12 | 13 | const { function: { name, args }, ...rest } = req.body 14 | 15 | const injectedInfo: chrome.scripting.ScriptInjection<any[], any> = { 16 | ...{ 17 | target: { tabId: req.sender.tab.id }, 18 | injectImmediately: true, 19 | world: 'MAIN', 20 | }, 21 | ...rest, 22 | func: functions[name], 23 | args, 24 | } 25 | 26 | const injectResult = await chrome.scripting.executeScript(injectedInfo) 27 | res.send(injectResult) 28 | } 29 | 30 | 31 | export default handler -------------------------------------------------------------------------------- /src/background/messages/inject-script.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { getScriptUrl, type InjectableScript } from '~background/scripts' 3 | import { dispatchFuncEvent, type FuncEventResult, isFuncEventResult } from '~utils/event' 4 | import { getResourceName } from '~utils/file' 5 | 6 | export type RequestBody = { 7 | target?: chrome.scripting.InjectionTarget 8 | fileUrl?: string 9 | func?: string 10 | args?: any[] 11 | } & { 12 | script?: InjectableScript<any> 13 | } 14 | 15 | export type ResponseBody = FuncEventResult & { result?: any } 16 | 17 | // the chrome.runtime undefined error will only throw on development at once 18 | const handler: PlasmoMessaging.MessageHandler<RequestBody, ResponseBody> = async (req, res) => { 19 | 20 | 21 | const target = req.body.target ?? { tabId: req.sender.tab.id } 22 | 23 | const fileUrl: string = req.body.script ? getScriptUrl(req.body.script) : req.body.fileUrl 24 | const funcName: string = req.body.script?.name ?? req.body.func 25 | const funcArgs: any[] = req.body.script?.args ?? req.body.args 26 | 27 | if (!fileUrl && !funcName) { 28 | throw new Error('no fileUrl or funcName provided in inject-script handler.') 29 | } 30 | 31 | const results: chrome.scripting.InjectionResult<any>[] = [] 32 | 33 | if (fileUrl) { 34 | const file = getResourceName(fileUrl) 35 | console.info('injecting file: ', file) 36 | results.push(...await chrome.scripting.executeScript({ 37 | target: target, 38 | injectImmediately: true, 39 | world: 'MAIN', 40 | files: [file], 41 | })) 42 | } 43 | 44 | if (funcName) { 45 | console.info('injecting function: ', funcName) 46 | console.info('injecting function args: ', funcArgs) 47 | results.push(...await chrome.scripting.executeScript({ 48 | target: target, 49 | injectImmediately: true, 50 | world: 'MAIN', 51 | func: dispatchFuncEvent, 52 | args: [funcName, ...(funcArgs ?? [])], 53 | })) 54 | } 55 | 56 | const finalResults = [] 57 | for (const result of results) { 58 | // if invoking function, always return function result 59 | if (isFuncEventResult(result.result)) { 60 | if (result.result.error) { 61 | return res.send({ success: false, error: result.result.error }) 62 | } 63 | return res.send({ success: true }) 64 | } 65 | 66 | // if injecting file, return the result of the last script 67 | if (result.result) { 68 | finalResults.push(result.result) 69 | } 70 | } 71 | 72 | return res.send({ success: true, result: finalResults }) 73 | } 74 | 75 | 76 | export default handler -------------------------------------------------------------------------------- /src/background/messages/migration-mv2.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging"; 2 | import migrateFromMV2 from "~migrations"; 3 | import type { Settings } from "~options/fragments"; 4 | 5 | export type ResponseBody = { data?: Settings, error?: string } 6 | 7 | const handler: PlasmoMessaging.MessageHandler<{}, ResponseBody> = async (req, res) => { 8 | try { 9 | const settings = await migrateFromMV2() 10 | res.send({ data: settings }) 11 | } catch (err: any) { 12 | console.error('Error while migrating settings from mv2', err) 13 | res.send({ error: err.message ?? String(err) }) 14 | } 15 | } 16 | 17 | export default handler -------------------------------------------------------------------------------- /src/background/messages/notify.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | 3 | import icon from 'raw:assets/icon.png' 4 | 5 | export type RequestBody = Partial<Omit<chrome.notifications.NotificationOptions<true>, 'buttons'>> & { 6 | messages?: string[], 7 | buttons?: (chrome.notifications.ButtonOptions & { clicked: (id: string, index: number) => void })[], 8 | onClicked?: (id: string) => void 9 | } 10 | 11 | async function createNotification(option: chrome.notifications.NotificationOptions<true>): Promise<string> { 12 | return new Promise((resolve, reject) => { 13 | chrome.notifications.create(option, id => { 14 | if (chrome.runtime.lastError) { 15 | reject(chrome.runtime.lastError) 16 | } else { 17 | resolve(id) 18 | } 19 | }) 20 | }) 21 | } 22 | 23 | const handler: PlasmoMessaging.MessageHandler<RequestBody, string> = async (req, res) => { 24 | const { title, message, messages, type, buttons, onClicked, ...option } = req.body 25 | const id = await createNotification({ 26 | type: type ?? 'basic', 27 | title, 28 | message: message ?? messages?.join('\n') ?? '', 29 | buttons: buttons?.map(({ clicked, ...option }) => option), 30 | ...option, 31 | iconUrl: icon 32 | }) 33 | const callbackFunc = (notificationId: string) => { 34 | if (id !== notificationId) return 35 | onClicked(notificationId) 36 | chrome.notifications.onClicked.removeListener(callbackFunc) 37 | } 38 | 39 | const buttonCallbackFunc = (notificationId: string, index: number) => { 40 | if (id !== notificationId) return 41 | const button = buttons[index] 42 | if (button?.clicked) button.clicked(id, index) 43 | chrome.notifications.onButtonClicked.removeListener(buttonCallbackFunc) 44 | } 45 | 46 | if (buttons?.length > 0) chrome.notifications.onButtonClicked.addListener(buttonCallbackFunc) 47 | if (onClicked) chrome.notifications.onClicked.addListener(callbackFunc) 48 | res.send(id) 49 | } 50 | 51 | export default handler 52 | -------------------------------------------------------------------------------- /src/background/messages/open-options.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging"; 2 | 3 | const handler: PlasmoMessaging.Handler = async (req, res) => { 4 | await new Promise<void>((res, ) => chrome.runtime.openOptionsPage(res)) 5 | } 6 | 7 | export default handler -------------------------------------------------------------------------------- /src/background/messages/open-tab.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | 3 | /** 4 | * Represents the body of a request to open a new tab. 5 | * 6 | * @example 7 | * // Example of a request body to open a new tab with a specific URL 8 | * const requestBody: RequestBody = { 9 | * url: "https://example.com", 10 | * active: true 11 | * }; 12 | * 13 | * @example 14 | * // Example of a request body with additional parameters and singleton criteria 15 | * const requestBody: RequestBody = { 16 | * url: "https://example.com", 17 | * params: { ref: "newsletter" }, 18 | * singleton: ["ref"] 19 | * }; 20 | */ 21 | export type RequestBody = { 22 | /** 23 | * The URL to open in the new tab. 24 | */ 25 | url?: string 26 | /** 27 | * The identifier of the tab to open. 28 | */ 29 | tab?: string 30 | /** 31 | * Whether the new tab should be active. 32 | */ 33 | active?: boolean 34 | /** 35 | * Additional parameters to include in the request. 36 | */ 37 | params?: Record<string, string> 38 | /** 39 | * Indicates if the tab should be a singleton. 40 | * If an array of strings is provided, it represents 41 | * only check the equality of those query params. 42 | */ 43 | singleton?: boolean | string[] 44 | } 45 | 46 | const handler: PlasmoMessaging.MessageHandler<RequestBody, chrome.tabs.Tab> = async (req, res) => { 47 | const { url, tab, active } = req.body 48 | const queryString = req.body.params ? `?${new URLSearchParams(req.body.params).toString()}` : '' 49 | const fullUrl = tab ? chrome.runtime.getURL(`/tabs/${tab}.html${queryString}`) : url + queryString 50 | const pathUrl = (tab ? chrome.runtime.getURL(`/tabs/${tab}.html`) : url) + '*' 51 | if (req.body.singleton) { 52 | const tabs = await chrome.tabs.query({ url: typeof req.body.singleton === 'boolean' ? fullUrl : pathUrl }) 53 | const tab = tabs.find(tab => 54 | typeof req.body.singleton === 'boolean' || 55 | req.body.singleton.some(param => new URL(tab.url).searchParams.get(param) === req.body.params[param]) 56 | ) 57 | if (tab) { 58 | res.send(await chrome.tabs.update(tab.id, { active: true })) 59 | return 60 | } 61 | } 62 | res.send(await chrome.tabs.create({ url: fullUrl, active })) 63 | } 64 | 65 | 66 | export default handler -------------------------------------------------------------------------------- /src/background/messages/open-window.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | 3 | export type RequestBody = chrome.windows.CreateData & { 4 | tab?: string, 5 | } 6 | 7 | const handler: PlasmoMessaging.MessageHandler<RequestBody, any> = async (req, res) => { 8 | const { url, tab } = req.body 9 | const result = await chrome.windows.create({ 10 | type: 'popup', 11 | focused: true, 12 | ...req.body, 13 | url: tab ? chrome.runtime.getURL(`/tabs/${tab}.html`) : url, 14 | }) 15 | res.send(result) 16 | } 17 | 18 | 19 | 20 | export default handler -------------------------------------------------------------------------------- /src/background/messages/request.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | 3 | export type RequestBody = { 4 | url: RequestInfo 5 | options?: RequestInit 6 | timeout?: number 7 | } 8 | 9 | 10 | export type ResponseBody = { 11 | error?: string 12 | data?: any 13 | } 14 | 15 | const handler: PlasmoMessaging.MessageHandler<RequestBody, ResponseBody> = async (req, r) => { 16 | try { 17 | const { url, timeout: timer = 15000, options = {} } = req.body 18 | const aborter = new AbortController() 19 | const timeout = setTimeout(() => aborter.abort(), timer) 20 | const res = await fetch(url, { signal: aborter.signal, ...options }) 21 | clearTimeout(timeout) 22 | if (!res.ok) throw new Error(`${res.statusText}(${res.status})`) 23 | const json = await res.json() 24 | r.send({ 25 | error: null, 26 | data: json 27 | }) 28 | } catch (err: Error | any) { 29 | r.send({ 30 | error: err.message, 31 | data: null 32 | }) 33 | } 34 | } 35 | 36 | export default handler -------------------------------------------------------------------------------- /src/background/ports.ts: -------------------------------------------------------------------------------- 1 | // follow from ./ports/*.ts 2 | import type { PlasmoMessaging } from "@plasmohq/messaging" 3 | 4 | 5 | export type PortingData = typeof ports 6 | 7 | interface PortData<T extends object, R = any> { 8 | default: PlasmoMessaging.PortHandler<T, R> 9 | } 10 | 11 | export type Payload<T> = T extends PortData<infer U> ? U : never 12 | 13 | export type Response<T> = T extends PortData<any, infer U> ? U : void 14 | 15 | const ports = { 16 | } -------------------------------------------------------------------------------- /src/background/scripts/clearIndexedDbTable/function.ts: -------------------------------------------------------------------------------- 1 | import type { Table } from "dexie" 2 | import db, { type CommonSchema, type TableType } from '~database' 3 | import { getAllTables } from '~utils/database' 4 | 5 | async function clearIndexedDbTable(table: TableType | 'all', room: string) { 6 | try { 7 | const tables: Table<CommonSchema, number>[] = [] 8 | if (table === 'all') { 9 | tables.push(...getAllTables()) 10 | } else { 11 | tables.push(db[table]) 12 | } 13 | if (room) { 14 | await Promise.all(tables.map(table => table.where({ room }).delete())) 15 | } else { 16 | await Promise.all(tables.map(table => table.clear())) 17 | } 18 | } catch (err) { 19 | console.error(err) 20 | } 21 | } 22 | 23 | export default clearIndexedDbTable -------------------------------------------------------------------------------- /src/background/scripts/clearIndexedDbTable/index.ts: -------------------------------------------------------------------------------- 1 | import url from 'url:./script.ts' 2 | 3 | import prototype from './function' 4 | 5 | export default { url, prototype } -------------------------------------------------------------------------------- /src/background/scripts/clearIndexedDbTable/script.ts: -------------------------------------------------------------------------------- 1 | import { injectFuncAsListener } from '~utils/event' 2 | 3 | import clearIndexedDbTable from './function' 4 | 5 | injectFuncAsListener(clearIndexedDbTable) -------------------------------------------------------------------------------- /src/background/scripts/index.ts: -------------------------------------------------------------------------------- 1 | import clearIndexedDbTable from './clearIndexedDbTable' 2 | 3 | export interface InjectableScript<T extends InjectableScriptType> { 4 | name: T 5 | args: InjectableScriptParameters<T> 6 | } 7 | 8 | export type InjectableScripts = typeof scripts 9 | 10 | export type InjectableScriptType = keyof InjectableScripts 11 | 12 | export type InjectableScriptParameters<T extends InjectableScriptType> = Parameters<InjectableScripts[T]['prototype']> 13 | 14 | export type InjectableScriptReturnType<T extends InjectableScriptType> = ReturnType<InjectableScripts[T]['prototype']> 15 | 16 | export function getScriptUrl<T extends InjectableScriptType>(script: InjectableScript<T>): string { 17 | return scripts[script.name].url 18 | } 19 | 20 | // getManifest error only happens on development environment 21 | const scripts = { 22 | clearIndexedDbTable, 23 | } 24 | 25 | export default scripts -------------------------------------------------------------------------------- /src/components/BLiveThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { isDarkThemeBilbili } from '~utils/bilibili'; 3 | 4 | import { useMutationObserver } from '@react-hooks-library/core'; 5 | 6 | import BJFThemeDarkContext from '~contexts/BLiveThemeDarkContext'; 7 | import BJFThemeProvider from './BJFThemeProvider'; 8 | 9 | const fetchDarkMode = () => /*isDarkTheme() &&*/ isDarkThemeBilbili() 10 | 11 | /** 12 | * BLiveThemeProvider component provides a theme context for the children components. 13 | * 14 | * @param children - The child components to be wrapped by the theme provider. 15 | * @param element - The element or elements to be used for checking dark/light theme from bilibili. If not provided, the document.documentElement will be used. 16 | * @returns The JSX element representing the theme provider. 17 | * 18 | * @example 19 | * ```tsx 20 | * <BLiveThemeProvider> 21 | * <App /> 22 | * </BLiveThemeProvider> 23 | * ``` 24 | */ 25 | function BLiveThemeProvider({ children, element }: { children: React.ReactNode, element?: Element | Element[] }): JSX.Element { 26 | 27 | const themeContext = useState<boolean>(fetchDarkMode) 28 | 29 | const [dark, setDark] = useState(fetchDarkMode) 30 | 31 | const controller = element ?? document.documentElement 32 | 33 | // watch bilibili theme changes 34 | useMutationObserver(document.documentElement, (mutations) => { 35 | for (const mutation of mutations) { 36 | if (mutation.type === 'attributes' && mutation.attributeName === 'lab-style') { 37 | setDark(fetchDarkMode) 38 | break 39 | } 40 | } 41 | }, { attributes: true }) 42 | 43 | useEffect(() => { 44 | setDark(fetchDarkMode) 45 | }, []) 46 | 47 | useEffect(() => { 48 | themeContext[1](dark) 49 | }, [dark]) 50 | 51 | return ( 52 | <BJFThemeDarkContext.Provider value={themeContext}> 53 | <BJFThemeProvider dark={dark} controller={controller}> 54 | {children} 55 | </BJFThemeProvider> 56 | </BJFThemeDarkContext.Provider> 57 | ) 58 | } 59 | 60 | 61 | 62 | export default BLiveThemeProvider -------------------------------------------------------------------------------- /src/components/ChatBubble.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@material-tailwind/react" 2 | import type { ReactNode } from "react" 3 | 4 | 5 | 6 | export type ChatBubbleProps = { 7 | avatar: string 8 | name: string 9 | messages: Chat[] 10 | loading?: boolean 11 | footer?: ReactNode 12 | } 13 | 14 | export type Chat = { 15 | text: ReactNode 16 | time?: string 17 | } 18 | 19 | 20 | function ChatBubble(props: ChatBubbleProps): JSX.Element { 21 | const { avatar, name, messages, loading, footer } = props 22 | return ( 23 | <div className="flex gap-2.5 mb-4"> 24 | <Avatar src={avatar} /> 25 | <div className="grid"> 26 | <h5 data-testid={`${name}-bubble-username`} className="text-gray-700 dark:text-gray-500 text-sm font-semibold leading-snug pb-1">{name}</h5> 27 | {messages.map((message, index) => ( 28 | <div key={index} className="max-w-full grid"> 29 | <div data-testid={`${name}-bubble-chat-${index}`} className="px-3.5 py-2 bg-gray-100 rounded justify-start items-center gap-3 inline-flex"> 30 | <div className={`text-gray-900 text-sm font-normal leading-snug list-inside ${loading ? 'animate-pulse' : ''}`}>{message.text}</div> 31 | </div> 32 | {message.time && ( 33 | <div data-testid={`${name}-bubble-time-${index}`} className="justify-end items-center inline-flex mb-2.5"> 34 | <h6 className="text-gray-500 text-xs font-normal leading-4 py-1">{message.time}</h6> 35 | </div> 36 | )} 37 | </div> 38 | ))} 39 | {footer} 40 | </div> 41 | </div> 42 | ) 43 | } 44 | 45 | export default ChatBubble -------------------------------------------------------------------------------- /src/components/ConditionalWrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType } from "react" 2 | 3 | 4 | /** 5 | * Props for the ConditionalWrapper component. 6 | * @template P - The type of additional props for the wrapped component. 7 | */ 8 | export type ConditionWrapperProps<P extends {}> = { 9 | condition: boolean; 10 | children: React.ReactNode; 11 | as: ComponentType<P>; 12 | } & P; 13 | 14 | /** 15 | * Wraps the children with a specified component if a condition is met, otherwise returns the children as is. 16 | * 17 | * @template P - The props type of the component. 18 | * @param {Object} props - The component props. 19 | * @param {boolean} props.condition - The condition to check. 20 | * @param {React.ReactNode} props.children - The children to wrap. 21 | * @param {React.ComponentType<P>} props.as - The component to wrap the children with. 22 | * @param {P} props.rest - The rest of the props to pass to the wrapped component. 23 | * @returns {JSX.Element} - The wrapped or unwrapped children. 24 | * 25 | * @example 26 | * // Wraps the children with a <div> component if the condition is true. 27 | * <ConditionalWrapper condition={true} as="div"> 28 | * <p>Hello, world!</p> 29 | * </ConditionalWrapper> 30 | * 31 | * @example 32 | * // Returns the children as is if the condition is false. 33 | * <ConditionalWrapper condition={false} as="div"> 34 | * <p>Hello, world!</p> 35 | * </ConditionalWrapper> 36 | */ 37 | function ConditionalWrapper<P extends {}>({ condition, children, as: Component, ...rest }: ConditionWrapperProps<P>): JSX.Element { 38 | if (condition) { 39 | return <Component {...rest as unknown as P}>{children}</Component> 40 | } else { 41 | return <>{children}</> 42 | } 43 | } 44 | 45 | 46 | export default ConditionalWrapper -------------------------------------------------------------------------------- /src/components/ShadowRoot.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react" 2 | import ReactShadowRoot from "react-shadow-root" 3 | import ShadowRootContext from "~contexts/ShadowRootContext" 4 | 5 | /** 6 | * Props for the ShadowRoot component. 7 | */ 8 | export type ShadowRootProps = { 9 | /** 10 | * The children of the ShadowRoot component. 11 | */ 12 | children: React.ReactNode 13 | /** 14 | * An array of styles to be applied to the ShadowRoot component. 15 | */ 16 | styles: string[] 17 | /** 18 | * Specifies whether the ShadowRoot component should have a line wrap or not. 19 | */ 20 | noWrap?: boolean 21 | } 22 | 23 | /** 24 | * Renders a component that creates a shadow root and provides it as a context value. 25 | * 26 | * @component 27 | * @example 28 | * // Example usage of ShadowRoot component 29 | * function App() { 30 | * const styles = ['body { background-color: lightgray }'] 31 | * return ( 32 | * <div> 33 | * <h1>App</h1> 34 | * <ShadowRoot styles={styles}> 35 | * <h2>ShadowRoot Content</h2> 36 | * </ShadowRoot> 37 | * </div> 38 | * ) 39 | * } 40 | * 41 | * @param {Object} props - The component props. 42 | * @param {ReactNode} props.children - The content to be rendered inside the shadow root. 43 | * @param {string[]} props.styles - An array of CSS styles to be applied to the shadow root. 44 | * @returns {JSX.Element} The rendered ShadowRoot component. 45 | */ 46 | function ShadowRoot({ children, styles, noWrap = false }: ShadowRootProps): JSX.Element { 47 | const reactShadowRoot = useRef<ReactShadowRoot>(null) 48 | const [shadowRoot, setShadowRoot] = useState<ShadowRoot>(null) 49 | 50 | useEffect(() => { 51 | if (reactShadowRoot.current) { 52 | setShadowRoot(reactShadowRoot.current.shadowRoot) 53 | console.debug("ShadowRoot created") 54 | } 55 | }, []) 56 | 57 | const child = shadowRoot && ( 58 | <ShadowRootContext.Provider value={shadowRoot}> 59 | {children} 60 | </ShadowRootContext.Provider> 61 | ) 62 | 63 | return ( 64 | <ReactShadowRoot ref={reactShadowRoot}> 65 | {styles?.map((style, i) => ( 66 | <style key={i}>{style}</style> 67 | ))} 68 | {noWrap ? child : ( 69 | <div className="relative">{child}</div> 70 | )} 71 | </ReactShadowRoot> 72 | ) 73 | } 74 | 75 | export default ShadowRoot 76 | -------------------------------------------------------------------------------- /src/components/ShadowStyle.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef, useState, type RefObject } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import ShadowRootContext from "~contexts/ShadowRootContext"; 4 | 5 | 6 | 7 | /** 8 | * promote a style element to the root level of ShadowRoot. 9 | * @param {Object} props - The component props. 10 | * @param {React.ReactNode} props.children - The children to be promoted inside the style element. 11 | * @returns {JSX.Element} - The rendered style element. 12 | * 13 | * @example 14 | * import { ShadowStyle } from "~components/ShadowStyle"; 15 | * 16 | * function MyComponent() { 17 | * return ( 18 | * // The style will be promoted to the root level of the ShadowRoot. 19 | * <ShadowStyle> 20 | * {` 21 | * .my-class { 22 | * color: red; 23 | * } 24 | * `} 25 | * </ShadowStyle> 26 | * ) 27 | * } 28 | */ 29 | function ShadowStyle({ children }: { children: React.ReactNode }): JSX.Element { 30 | 31 | const host = useContext(ShadowRootContext) 32 | 33 | if (!host) { 34 | console.warn('No ShadowRoot found: ShadowStyle must be used inside a ShadowRoot') 35 | } 36 | 37 | return host ? createPortal(<style>{children}</style>, host) : <></> 38 | 39 | } 40 | 41 | 42 | export default ShadowStyle -------------------------------------------------------------------------------- /src/components/TailwindScope.tsx: -------------------------------------------------------------------------------- 1 | import styleText from 'data-text:~style.css'; 2 | import ShadowRoot from '~components/ShadowRoot'; 3 | 4 | /** 5 | * Renders a component that applies a Tailwind CSS scope to its children. 6 | * 7 | * @param {Object} props - The component props. 8 | * @param {React.ReactNode} props.children - The children to be rendered within the Tailwind scope. 9 | * @param {boolean} [props.dark] - Optional. Specifies whether the dark mode should be applied. 10 | * @returns {JSX.Element} The rendered TailwindScope component. 11 | */ 12 | function TailwindScope({ children, dark, noWrap = false }: { children: React.ReactNode, dark?: boolean, noWrap?: boolean }): JSX.Element { 13 | return ( 14 | <div className={dark === true ? `dark` : ''}> 15 | <ShadowRoot styles={[styleText]} noWrap={noWrap}> 16 | {children} 17 | </ShadowRoot> 18 | </div> 19 | ) 20 | } 21 | 22 | export default TailwindScope -------------------------------------------------------------------------------- /src/contents/index/components/ButtonList.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@material-tailwind/react" 2 | import { useCallback, useContext } from "react" 3 | import { sendForward } from "~background/forwards" 4 | import ContentContext from "~contexts/ContentContexts" 5 | import { usePopupWindow } from "~hooks/window" 6 | import { sendMessager } from "~utils/messaging" 7 | 8 | 9 | function ButtonList(): JSX.Element { 10 | 11 | const streamInfo = useContext(ContentContext) 12 | 13 | const { settings, info } = streamInfo 14 | const { "settings.display": displaySettings, "settings.features": { common: { enabledPip, monitorWindow }} } = settings 15 | 16 | const { createPopupWindow } = usePopupWindow(enabledPip, { 17 | width: 700, 18 | height: 450 19 | }) 20 | 21 | const restart = useCallback(() => sendForward('background', 'redirect', { target: 'content-script', command: 'command', body: { command: 'restart' }, queryInfo: { url: '*://live.bilibili.com/*' } }), []) 22 | const addBlackList = () => confirm(`确定添加房间 ${info.room}${info.room === info.shortRoom ? '' : `(${info.shortRoom})`} 到黑名单?`) && sendMessager('add-black-list', { roomId: info.room }) 23 | const openSettings = useCallback(() => sendMessager('open-options'), []) 24 | const openMonitor = createPopupWindow(`stream.html`, { 25 | roomId: info.room, 26 | title: info.title, 27 | owner: info.username, 28 | muted: enabledPip.toString() //in iframe, only muted video can autoplay 29 | }) 30 | 31 | return ( 32 | <div id="bjf-global-buttons" className="flex flex-col px-2 py-3 gap-4"> 33 | {displaySettings.blackListButton && 34 | <Button variant="outlined" size="lg" className="text-lg" onClick={addBlackList}>添加到黑名单</Button>} 35 | {displaySettings.settingsButton && 36 | <Button variant="outlined" size="lg" className="text-lg" onClick={openSettings}>进入设置</Button>} 37 | {displaySettings.restartButton && 38 | <Button variant="outlined" size="lg" className="text-lg" onClick={restart}>重新启动</Button>} 39 | {monitorWindow && 40 | <Button variant="outlined" size="lg" className="text-lg" onClick={openMonitor}>弹出直播视窗</Button>} 41 | {(info.isTheme && displaySettings.themeToNormalButton) && 42 | <Button variant="outlined" size="lg" className="text-lg" onClick={() => window.open(`https://live.bilibili.com/blanc/${info.room}`)}>返回非海报界面</Button> 43 | } 44 | </div> 45 | ) 46 | } 47 | 48 | export default ButtonList -------------------------------------------------------------------------------- /src/contents/index/components/FloatingMenuButtion.tsx: -------------------------------------------------------------------------------- 1 | import extIcon from 'raw:assets/icon.png'; 2 | 3 | 4 | function FloatingMenuButton({ toggle }: { toggle: VoidFunction }) { 5 | return ( 6 | <div id="bjf-main-menu-button" onClick={toggle} className="cursor-pointer group fixed flex justify-end top-72 left-0 rounded-r-2xl shadow-md p-3 bg-white dark:bg-gray-800 transition-transform transform -ml-6 w-28 hover:translate-x-5 overflow-hidden"> 7 | <button className="flex flex-col justify-center items-center text-center gap-3"> 8 | <img src={extIcon} alt="bjf" height={26} width={26} className="group-hover:animate-pulse" /> 9 | <span className="text-md text-gray-800 dark:text-white">功能菜单</span> 10 | </button> 11 | </div> 12 | ) 13 | } 14 | 15 | 16 | export default FloatingMenuButton -------------------------------------------------------------------------------- /src/contents/index/components/FooterButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, Tooltip } from "@material-tailwind/react"; 2 | 3 | function FooterButton({ children, title, onClick }: { children: React.ReactNode, title: string, onClick?: VoidFunction }): JSX.Element { 4 | return ( 5 | <Tooltip content={title} placement="bottom"> 6 | <IconButton onClick={onClick} variant="text" size="lg" title={title} className="rounded-full shadow-md bg-white"> 7 | {children} 8 | </IconButton> 9 | </Tooltip> 10 | ) 11 | } 12 | 13 | export default FooterButton -------------------------------------------------------------------------------- /src/contents/index/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Typography, IconButton } from "@material-tailwind/react" 2 | import { useContext } from "react" 3 | import ContentContext from "~contexts/ContentContexts" 4 | 5 | 6 | function Header({ closeDrawer }: { closeDrawer: VoidFunction }): JSX.Element { 7 | 8 | const { info } = useContext(ContentContext) 9 | 10 | return ( 11 | <div className="mb-3 flex items-center justify-between text-ellipsis"> 12 | <div className="flex justify-start items-start flex-col"> 13 | <Typography variant="h5" className="dark:text-white"> 14 | {info.title} 15 | </Typography> 16 | <Typography variant="small" className="dark:text-white"> 17 | {info.username} 的直播间 18 | </Typography> 19 | </div> 20 | <IconButton variant="text" onClick={closeDrawer}> 21 | <svg 22 | xmlns="http://www.w3.org/2000/svg" 23 | fill="none" 24 | viewBox="0 0 24 24" 25 | strokeWidth={2} 26 | stroke="currentColor" 27 | className="h-5 w-5" 28 | > 29 | <path 30 | strokeLinecap="round" 31 | strokeLinejoin="round" 32 | d="M6 18L18 6M6 6l12 12" 33 | /> 34 | </svg> 35 | </IconButton> 36 | </div> 37 | ) 38 | } 39 | 40 | export default Header -------------------------------------------------------------------------------- /src/contexts/BLiveThemeDarkContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { UseState } from "~types/common"; 3 | 4 | const BJFThemeDarkContext = createContext<UseState<boolean>>(null) 5 | 6 | export default BJFThemeDarkContext -------------------------------------------------------------------------------- /src/contexts/ContentContexts.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { StreamInfo } from "~api/bilibili"; 3 | import type { Settings } from "~options/fragments"; 4 | 5 | export type ContentContextProps = { 6 | info: StreamInfo 7 | settings: Settings 8 | } 9 | 10 | const ContentContext = createContext<ContentContextProps>(null) 11 | 12 | export default ContentContext -------------------------------------------------------------------------------- /src/contexts/GenericContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | const GenericContext = createContext(null) 4 | 5 | export default GenericContext -------------------------------------------------------------------------------- /src/contexts/JimakuFeatureContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { type FeatureSettingSchema as FeatureJimakuSchema } from "~options/features/jimaku"; 3 | 4 | const JimakuFeatureContext = createContext<FeatureJimakuSchema>(null) 5 | 6 | export default JimakuFeatureContext -------------------------------------------------------------------------------- /src/contexts/RecorderFeatureContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { FeatureSettingSchema as RecorderFeatureSchema } from "~options/features/recorder"; 3 | 4 | const RecorderFeatureContext = createContext<RecorderFeatureSchema>(null) 5 | 6 | export default RecorderFeatureContext -------------------------------------------------------------------------------- /src/contexts/ShadowRootContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | 4 | const ShadowRootContext = createContext<ShadowRoot>(null) 5 | 6 | export default ShadowRootContext -------------------------------------------------------------------------------- /src/contexts/SuperChatFeatureContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { FeatureSettingSchema as SuperChatFeatureSchema } from "~options/features/superchat"; 3 | 4 | const SuperChatFeatureContext = createContext<SuperChatFeatureSchema>(null) 5 | 6 | export default SuperChatFeatureContext -------------------------------------------------------------------------------- /src/database/index.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { type Table } from 'dexie' 2 | 3 | import migrate from './migrations' 4 | 5 | export interface CommonSchema { 6 | id?: number 7 | date: string 8 | room: string 9 | } 10 | 11 | export const commonSchema = "++id, date, room, " 12 | 13 | export type TableType = { [K in keyof IndexedDatabase]: IndexedDatabase[K] extends Table ? K : never }[keyof IndexedDatabase] 14 | 15 | export type RecordType<T extends TableType> = IndexedDatabase[T] extends Table<infer R> ? R : never 16 | 17 | export class IndexedDatabase extends Dexie { 18 | public constructor() { 19 | super("bilibili-vup-stream-enhancer") 20 | migrate(this) 21 | } 22 | } 23 | 24 | const db = new IndexedDatabase() 25 | 26 | export default db -------------------------------------------------------------------------------- /src/database/migrations.ts: -------------------------------------------------------------------------------- 1 | import type Dexie from "dexie" 2 | import { commonSchema } from '~database' 3 | 4 | export default function (db: Dexie) { 5 | 6 | // version 1 7 | db.version(1).stores({ 8 | superchats: commonSchema + "text, scId, backgroundColor, backgroundImage, backgroundHeaderColor, userIcon, nameColor, uid, uname, price, message, hash, timestamp", 9 | jimakus: commonSchema + "text, uid, uname, hash" 10 | }) 11 | 12 | // version 2 13 | db.version(2).stores({ 14 | streams: commonSchema + "content, order" 15 | }) 16 | 17 | } -------------------------------------------------------------------------------- /src/database/tables/jimaku.d.ts: -------------------------------------------------------------------------------- 1 | import { Table } from 'dexie' 2 | import { CommonSchema } from '~database' 3 | 4 | declare module '~database' { 5 | interface IndexedDatabase { 6 | jimakus: Table<Jimaku, number> 7 | } 8 | } 9 | 10 | interface Jimaku extends CommonSchema { 11 | text: string 12 | uid: number 13 | uname: string 14 | hash: string 15 | } 16 | -------------------------------------------------------------------------------- /src/database/tables/stream.d.ts: -------------------------------------------------------------------------------- 1 | import { Table } from 'dexie' 2 | import { CommonSchema } from '~database' 3 | 4 | declare module '~database' { 5 | interface IndexedDatabase { 6 | streams: Table<Stream, number> 7 | } 8 | } 9 | 10 | interface Stream extends CommonSchema { 11 | content: Blob 12 | order: number 13 | } -------------------------------------------------------------------------------- /src/database/tables/superchat.d.ts: -------------------------------------------------------------------------------- 1 | import { Table } from 'dexie' 2 | import { CommonSchema } from '~database' 3 | 4 | declare module '~database' { 5 | interface IndexedDatabase { 6 | superchats: Table<Superchat, number> 7 | } 8 | } 9 | 10 | interface Superchat extends CommonSchema { 11 | scId: number 12 | backgroundColor: string 13 | backgroundImage: string 14 | backgroundHeaderColor: string 15 | userIcon: string 16 | nameColor: string 17 | uid: number 18 | uname: string 19 | price: number 20 | message: string 21 | hash: string 22 | timestamp: number 23 | date: string 24 | } 25 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | import * as jimaku from './jimaku' 2 | import * as superchat from './superchat' 3 | import * as recorder from './recorder' 4 | 5 | import type { StreamInfo } from '~api/bilibili' 6 | import type { Settings } from '~options/fragments' 7 | 8 | export type FeatureHookRender = (settings: Readonly<Settings>, info: StreamInfo) => Promise<(React.ReactPortal | React.ReactNode)[] | string | undefined> 9 | 10 | export type FeatureAppRender = React.FC<{}> 11 | 12 | export type FeatureType = keyof typeof features 13 | 14 | export interface FeatureHandler { 15 | default: FeatureHookRender, 16 | App?: FeatureAppRender, 17 | FeatureContext?: React.Context<any> 18 | } 19 | 20 | const features = { 21 | jimaku, 22 | superchat, 23 | recorder 24 | } 25 | 26 | 27 | export default (features as Record<FeatureType, FeatureHandler>) -------------------------------------------------------------------------------- /src/features/jimaku/components/ButtonSwitchList.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react" 2 | 3 | 4 | export type ButtonSwitchListProps = { 5 | onClick: VoidFunction 6 | switched: boolean 7 | } 8 | 9 | function ButtonSwitchList(props: ButtonSwitchListProps): JSX.Element { 10 | 11 | const { onClick, switched } = props 12 | 13 | return ( 14 | <Fragment> 15 | <button 16 | style={{ 17 | backgroundColor: switched ? '#4CAF50' : '#f44336', 18 | color: 'white' 19 | }} 20 | onClick={onClick} 21 | className="px-[5px] ml-[5px] py-[3px] rounded-md hover:brightness-90 shadow-md transition-all duration-300 ease-in-out" 22 | > 23 | 切换字幕按钮列表 24 | </button> 25 | </Fragment> 26 | ) 27 | } 28 | 29 | 30 | export default ButtonSwitchList -------------------------------------------------------------------------------- /src/features/jimaku/components/JimakuAreaSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useContext } from "react"; 2 | import JimakuFeatureContext from "~contexts/JimakuFeatureContext"; 3 | 4 | function JimakuAreaSkeleton(): JSX.Element { 5 | 6 | const { jimakuZone: jimakuSettings, buttonZone: buttonSettings } = useContext(JimakuFeatureContext) 7 | const { backgroundHeight, backgroundColor, color, firstLineSize, lineGap } = jimakuSettings 8 | const { backgroundListColor } = buttonSettings 9 | 10 | return ( 11 | <Fragment> 12 | <div style={{ height: backgroundHeight, backgroundColor }} className="flex justify-center items-start"> 13 | <h1 style={{ color, fontSize: firstLineSize, marginTop: lineGap }} className="animate-pulse font-bold">字幕加载中...</h1> 14 | </div> 15 | <div style={{ backgroundColor: backgroundListColor }} className="text-center overflow-x-auto flex justify-center gap-3"> 16 | {...Array(3).fill(0).map((_, i) => { 17 | // make random skeleton width 18 | const width = [120, 160, 130][i] 19 | return ( 20 | <div key={i} style={{ width: width }} className="m-[5px] px-[20px] py-[10px] rounded-md text-[15px] animate-pulse bg-gray-300"> 21 |   22 | </div> 23 | ) 24 | })} 25 | </div> 26 | </Fragment> 27 | ) 28 | } 29 | 30 | export default JimakuAreaSkeleton -------------------------------------------------------------------------------- /src/features/jimaku/components/JimakuAreaSkeletonError.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useContext } from "react" 2 | import JimakuFeatureContext from "~contexts/JimakuFeatureContext" 3 | 4 | export type JimakuAreaSkeletonErrorProps = { 5 | error: Error | any 6 | retry: VoidFunction 7 | } 8 | 9 | function JimakuAreaSkeletonError({ error, retry }: JimakuAreaSkeletonErrorProps): JSX.Element { 10 | 11 | const { jimakuZone: jimakuSettings, buttonZone: buttonSettings } = useContext(JimakuFeatureContext) 12 | const { backgroundHeight, backgroundColor, firstLineSize, lineGap, size } = jimakuSettings 13 | const { backgroundListColor } = buttonSettings 14 | 15 | return ( 16 | <Fragment> 17 | <div style={{ height: backgroundHeight, backgroundColor }} className="flex flex-col justify-start text-lg items-center gap-3 text-red-400"> 18 | <h1 style={{ fontSize: firstLineSize, margin: `${lineGap}px 0px` }} className="font-bold">加载失败</h1> 19 | <span style={{ fontSize: size }}>{String(error)}</span> 20 | </div> 21 | <div style={{ backgroundColor: backgroundListColor }} className="text-center overflow-x-auto flex justify-center gap-3"> 22 | <button onClick={retry} className="m-[5px] px-[20px] py-[10px] text-[15px] bg-red-700 rounded-md"> 23 | 重试 24 | </button> 25 | </div> 26 | </Fragment> 27 | ) 28 | } 29 | 30 | export default JimakuAreaSkeletonError -------------------------------------------------------------------------------- /src/features/jimaku/components/JimakuButton.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, type MouseEventHandler } from 'react'; 2 | import JimakuFeatureContext from '~contexts/JimakuFeatureContext'; 3 | 4 | export type JimakuButtonProps = { 5 | onClick?: MouseEventHandler<HTMLButtonElement>, 6 | children: React.ReactNode 7 | } 8 | 9 | function JimakuButton({ onClick, children }: JimakuButtonProps): JSX.Element { 10 | 11 | const { buttonZone: btnStyle } = useContext(JimakuFeatureContext) 12 | 13 | return ( 14 | <button 15 | onClick={onClick} 16 | style={{ 17 | backgroundColor: btnStyle.backgroundColor, 18 | color: btnStyle.textColor 19 | }} 20 | className="m-[5px] px-[20px] py-[10px] rounded-md text-[15px] cursor-pointer"> 21 | {children} 22 | </button> 23 | ) 24 | } 25 | 26 | export default JimakuButton -------------------------------------------------------------------------------- /src/features/jimaku/components/JimakuLine.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useRowOptimizer } from '~hooks/optimizer'; 3 | 4 | // here must be a subset of database Jimaku schema 5 | export type Jimaku = { 6 | date: string 7 | text: string 8 | uid: number 9 | uname: string 10 | hash: string 11 | } 12 | 13 | export type JimakuLineProps = { 14 | item: Jimaku 15 | show: (e: React.MouseEvent<HTMLParagraphElement>) => void 16 | index: number 17 | observer: React.MutableRefObject<IntersectionObserver | null> 18 | } 19 | 20 | 21 | function JimakuLine({ item, show, index, observer }: JimakuLineProps): JSX.Element { 22 | 23 | const ref = useRowOptimizer<HTMLParagraphElement>(observer) 24 | 25 | return ( 26 | <p ref={ref} onContextMenu={show} jimaku-hash={item.hash} jimaku-index={index}> 27 | {item.text} 28 | </p> 29 | ) 30 | } 31 | 32 | export default memo(JimakuLine) 33 | -------------------------------------------------------------------------------- /src/features/recorder/components/ProgressText.tsx: -------------------------------------------------------------------------------- 1 | import type { ProgressEvent } from "@ffmpeg/ffmpeg/dist/esm/types" 2 | import { Spinner, Progress } from "@material-tailwind/react" 3 | import { useState } from "react" 4 | import TailwindScope from "~components/TailwindScope" 5 | import type { FFMpegHooks } from "~hooks/ffmpeg" 6 | import { useAsyncEffect } from "~hooks/life-cycle" 7 | 8 | 9 | function ProgressText({ ffmpeg }: { ffmpeg: Promise<FFMpegHooks> }) { 10 | 11 | const [progress, setProgress] = useState<ProgressEvent>(null) 12 | 13 | useAsyncEffect( 14 | async () => { 15 | const ff = await ffmpeg 16 | ff.onProgress(setProgress) 17 | }, 18 | async () => { }, 19 | (err) => { 20 | console.error('unexpected: ', err) 21 | }, 22 | [ffmpeg]) 23 | 24 | if (!progress) { 25 | return `编译视频中...` 26 | } 27 | 28 | const progressValid = progress.progress > 0 && progress.progress <= 1 29 | 30 | return ( 31 | <TailwindScope> 32 | <div className="flex justify-center flex-col space-y-2"> 33 | <div className="flex flex-row items-center space-x-2"> 34 | <div> 35 | <Spinner className="h-5 w-5" /> 36 | </div> 37 | <div> 38 | {`编译视频中... ${progressValid ? `(${Math.round(progress.progress * 10000) / 100}%)` : ''}`} 39 | </div> 40 | </div> 41 | {progressValid && <Progress color="blue" value={progress.progress * 100} />} 42 | </div> 43 | </TailwindScope> 44 | ) 45 | 46 | } 47 | 48 | export default ProgressText -------------------------------------------------------------------------------- /src/features/recorder/index.tsx: -------------------------------------------------------------------------------- 1 | import RecorderFeatureContext from "~contexts/RecorderFeatureContext"; 2 | import type { FeatureHookRender } from "~features"; 3 | import { sendMessager } from "~utils/messaging"; 4 | import RecorderLayer from "./components/RecorderLayer"; 5 | 6 | export const FeatureContext = RecorderFeatureContext 7 | 8 | const handler: FeatureHookRender = async (settings, info) => { 9 | 10 | const { error, data: urls } = await sendMessager('get-stream-urls', { roomId: info.room }) 11 | if (error) { 12 | console.warn('啟用快速切片功能失敗: ', error) 13 | return '啟用快速切片功能失敗: '+ error // 返回 string 以顯示錯誤 14 | } 15 | 16 | return [ 17 | <RecorderLayer key={info.room} urls={urls} /> 18 | ] 19 | } 20 | 21 | 22 | 23 | 24 | export default handler -------------------------------------------------------------------------------- /src/features/recorder/recorders/buffer.ts: -------------------------------------------------------------------------------- 1 | import { recordStream, type PlayerOptions, type VideoInfo } from "~players"; 2 | import type { StreamPlayer } from "~types/media"; 3 | import { Recorder } from "~types/media"; 4 | import { toArrayBuffer } from "~utils/binary"; 5 | import { type ChunkData } from "."; 6 | 7 | class BufferRecorder extends Recorder<PlayerOptions> { 8 | 9 | private player: StreamPlayer = null 10 | private info: VideoInfo = null 11 | 12 | async start(): Promise<void> { 13 | let i = 0 14 | this.player = await recordStream(this.urls, (buffer) => this.onBufferArrived(++i, buffer), this.options) 15 | this.appendBufferChecker() 16 | this.info = this.player.videoInfo 17 | } 18 | 19 | private async onBufferArrived(order: number, buffer: ArrayBufferLike): Promise<void> { 20 | try { 21 | const ab = toArrayBuffer(buffer) 22 | const blob = new Blob([ab], { type: 'application/octet-stream' }) 23 | return this.saveChunk(blob, order) 24 | }catch(err){ 25 | console.error('failed to save chunk: ', err) 26 | throw err 27 | } 28 | } 29 | 30 | async loadChunkData(flush: boolean = true): Promise<ChunkData> { 31 | const chunks = await this.loadChunks(flush) 32 | return { 33 | chunks, 34 | info: this.info 35 | } 36 | } 37 | 38 | stop(): void { 39 | clearInterval(this.bufferAppendChecker) 40 | this.player?.stopAndDestroy() 41 | this.player = null 42 | } 43 | 44 | get recording(): boolean { 45 | return !!this.player 46 | } 47 | 48 | set onerror(handler: (error: Error) => void) { 49 | if (!this.player) return 50 | if (this.errorHandler) this.player.off('error', this.errorHandler) 51 | this.player.on('error', handler) 52 | this.errorHandler = handler 53 | } 54 | 55 | } 56 | 57 | export default BufferRecorder -------------------------------------------------------------------------------- /src/features/recorder/recorders/index.ts: -------------------------------------------------------------------------------- 1 | import type { StreamUrls } from "~background/messages/get-stream-urls" 2 | import type { PlayerOptions, VideoInfo } from "~players" 3 | import { Recorder } from "~types/media" 4 | import buffer from "./buffer" 5 | import capture, { type CaptureOptions } from "./capture" 6 | 7 | export type ChunkData = { 8 | chunks: Blob[] 9 | info: VideoInfo 10 | } 11 | 12 | export type RecorderType = keyof typeof recorders 13 | 14 | export type RecorderPayload = { 15 | buffer: PlayerOptions 16 | capture: CaptureOptions 17 | } 18 | 19 | const recorders = { 20 | buffer, 21 | capture, 22 | } 23 | 24 | function createRecorder<T extends RecorderType>(room: string, urls: StreamUrls, type: T, options: RecorderPayload[T]): Recorder { 25 | const Recorder = recorders[type] 26 | if (!Recorder) { 27 | throw new Error('unsupported recorder type: ' + type) 28 | } 29 | return new Recorder(room, urls, options) 30 | } 31 | 32 | export default createRecorder -------------------------------------------------------------------------------- /src/features/superchat/components/SuperChatArea.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useRef } from "react" 2 | import ContentContext from "~contexts/ContentContexts" 3 | import { useScrollOptimizer } from "~hooks/optimizer" 4 | import { useRecords } from "~hooks/records" 5 | import SuperChatItem, { type SuperChatCard } from "./SuperChatItem" 6 | import SuperChatFeatureContext from "~contexts/SuperChatFeatureContext" 7 | import BJFThemeDarkContext from "~contexts/BLiveThemeDarkContext" 8 | 9 | 10 | export type SuperChatAreaProps = { 11 | superchats: SuperChatCard[], 12 | clearSuperChat: VoidFunction 13 | } 14 | 15 | function SuperChatArea(props: SuperChatAreaProps): JSX.Element { 16 | 17 | const [ themeDark ] = useContext(BJFThemeDarkContext) 18 | const { settings, info } = useContext(ContentContext) 19 | const { buttonColor } = useContext(SuperChatFeatureContext) 20 | const { superchats, clearSuperChat } = props 21 | const { enabledRecording } = settings['settings.features'] 22 | 23 | const listRef = useRef<HTMLElement>(null) 24 | 25 | const observer = useScrollOptimizer({ root: listRef, rootMargin: '100px', threshold: 0.13 }) 26 | 27 | const { downloadRecords, deleteRecords } = useRecords(info.room, superchats, { 28 | feature: 'superchat', 29 | table: enabledRecording.includes('superchat') ? 'superchats' : undefined, 30 | description: '醒目留言', 31 | format: (superchat) => `[${superchat.date}] [¥${superchat.price}] ${superchat.uname}(${superchat.uid}): ${superchat.message}`, 32 | clearRecords: clearSuperChat, 33 | reverse: true // superchat always revsered 34 | }) 35 | 36 | return ( 37 | <div className="p-[5px] pt-[5px] rounded-md inline-block"> 38 | <section className="px-[5px] flex justify-center items-center gap-2"> 39 | <button style={{backgroundColor: themeDark ? '#424242' : buttonColor}} onClick={downloadRecords} className="hover:brightness-90 dark:bg-gray-700 dark:hover:bg-gray-800 text-white font-bold py-2 px-4 rounded-sm"> 40 | 导出醒目留言记录 41 | </button> 42 | <button style={{backgroundColor: themeDark ? '#424242' : buttonColor}} onClick={deleteRecords} className="hover:brightness-90 dark:bg-gray-700 dark:hover:bg-gray-800 text-white font-bold py-2 px-4 rounded-sm"> 43 | 刪除所有醒目留言记录 44 | </button> 45 | </section> 46 | <hr className="mx-[5px] my-3 border-black" /> 47 | <section ref={listRef} className="bjf-scrollbar flex flex-col gap-3 overflow-y-auto py-[5px] overflow-x-hidden w-[300px] h-[300px]"> 48 | {superchats.map((item) => ( 49 | <div key={item.hash} className="px-2" superchat-hash={item.hash}> 50 | <SuperChatItem {...item} observer={observer} /> 51 | </div> 52 | ))} 53 | </section> 54 | </div> 55 | ) 56 | 57 | 58 | } 59 | 60 | 61 | 62 | export default SuperChatArea -------------------------------------------------------------------------------- /src/features/superchat/components/SuperChatButtonSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@material-tailwind/react" 2 | 3 | function SuperChatButtonSkeleton(): JSX.Element { 4 | return ( 5 | <div 6 | style={{ 7 | left: window.innerWidth - 500, 8 | top: 96, 9 | width: 85, 10 | height: 85 11 | }} 12 | className="absolute rounded-full bg-white p-3 drop-shadow-lg flex flex-col justify-center items-center gap-3 text-black"> 13 | <Spinner /> 14 | <div>醒目留言</div> 15 | </div> 16 | ) 17 | } 18 | 19 | export default SuperChatButtonSkeleton -------------------------------------------------------------------------------- /src/features/superchat/components/SuperChatFloatingButton.tsx: -------------------------------------------------------------------------------- 1 | import { Item, Menu, useContextMenu } from 'react-contexify'; 2 | import styleText from 'data-text:react-contexify/dist/ReactContexify.css'; 3 | import { Fragment, useContext } from 'react'; 4 | import DraggableFloatingButton from '~components/DraggableFloatingButton'; 5 | import BJFThemeDarkContext from '~contexts/BLiveThemeDarkContext'; 6 | import SuperChatFeatureContext from '~contexts/SuperChatFeatureContext'; 7 | 8 | export type SuperChatFloatingButtonProps = { 9 | children: React.ReactNode 10 | } 11 | 12 | function SuperChatFloatingButton({ children }: SuperChatFloatingButtonProps): JSX.Element { 13 | 14 | const [themeDark] = useContext(BJFThemeDarkContext) 15 | const { floatingButtonColor } = useContext(SuperChatFeatureContext) 16 | 17 | const { show } = useContextMenu({ 18 | id: 'superchat-menu' 19 | }) 20 | 21 | return ( 22 | <Fragment> 23 | <style>{styleText}</style> 24 | <DraggableFloatingButton style={{ backgroundColor: themeDark ? '#424242' : floatingButtonColor }} onClick={e => show({ event: e })} className='hover:brightness-90 duration-150 dark:bg-gray-700 dark:hover:bg-gray-800 text-white'> 25 | <div className="group-hover:animate-pulse"> 26 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10"> 27 | <path strokeLinecap="round" strokeLinejoin="round" d="m9 7.5 3 4.5m0 0 3-4.5M12 12v5.25M15 12H9m6 3H9m12-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> 28 | </svg> 29 | </div> 30 | <div>醒目留言</div> 31 | </DraggableFloatingButton> 32 | <Menu onKeyDown={e => e.preventDefault()} id="superchat-menu" style={{ backgroundColor: '#f1f1f1', overscrollBehaviorY: 'none' }}> 33 | <Item className='hidden'>{''}</Item> 34 | {children} 35 | </Menu> 36 | </Fragment> 37 | ) 38 | } 39 | 40 | export default SuperChatFloatingButton -------------------------------------------------------------------------------- /src/features/superchat/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FeatureHookRender } from ".."; 2 | 3 | 4 | import { getSuperChatList } from "~api/bilibili"; 5 | import OfflineRecordsProvider from "~components/OfflineRecordsProvider"; 6 | import SuperChatFeatureContext from "~contexts/SuperChatFeatureContext"; 7 | import { randomString, toStreamingTime, toTimer } from "~utils/misc"; 8 | import SuperChatButtonSkeleton from "./components/SuperChatButtonSkeleton"; 9 | import SuperChatCaptureLayer from "./components/SuperChatCaptureLayer"; 10 | import { type SuperChatCard } from "./components/SuperChatItem"; 11 | 12 | 13 | export const FeatureContext = SuperChatFeatureContext 14 | 15 | const handler: FeatureHookRender = async (settings, info) => { 16 | 17 | const { common: { useStreamingTime }, enabledRecording } = settings['settings.features'] 18 | 19 | const list = await getSuperChatList(info.room) 20 | const superchats: SuperChatCard[] = (list ?? []) 21 | .sort((a, b) => b.start_time - a.start_time) 22 | .map((item) => ({ 23 | id: item.id, 24 | backgroundColor: item.background_bottom_color, 25 | backgroundImage: item.background_image, 26 | backgroundHeaderColor: item.background_color, 27 | userIcon: item.user_info.face, 28 | nameColor: '#646c7a', 29 | uid: item.uid, 30 | uname: item.user_info.uname, 31 | price: item.price, 32 | message: item.message, 33 | timestamp: item.start_time, 34 | date: useStreamingTime ? toTimer(item.start_time - info.liveTime) : toStreamingTime(item.start_time), 35 | hash: `${randomString()}${item.id}`, 36 | persist: false 37 | })) 38 | 39 | return [ 40 | <OfflineRecordsProvider 41 | key={info.room} 42 | feature="superchat" 43 | room={info.room} 44 | settings={settings} 45 | table="superchats" 46 | filter={(superchat) => superchats.every(s => s.id !== superchat.scId)} 47 | sortBy="timestamp" 48 | reverse={true} 49 | loading={<SuperChatButtonSkeleton />} 50 | error={(err) => <></>} 51 | > 52 | {(records) => { 53 | const offlineRecords = [...superchats, ...records.map((r) => ({ ...r, id: r.scId, persist: true }))] 54 | return (info.status === 'online' || (enabledRecording.includes('superchat') && offlineRecords.length > 0)) && <SuperChatCaptureLayer offlineRecords={offlineRecords} /> 55 | }} 56 | </OfflineRecordsProvider> 57 | ] 58 | } 59 | 60 | 61 | export default handler -------------------------------------------------------------------------------- /src/ffmpeg/core.ts: -------------------------------------------------------------------------------- 1 | import type { Cleanup, FFMpegCore } from "~ffmpeg"; 2 | 3 | import type { FFmpeg } from "@ffmpeg/ffmpeg"; 4 | import { toBlobURL } from "@ffmpeg/util"; 5 | import ffmpegWorkerJs from 'url:assets/ffmpeg/worker.js'; 6 | import { randomString } from "~utils/misc"; 7 | 8 | const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/esm" 9 | 10 | export class SingleThread implements FFMpegCore { 11 | 12 | private ffmpeg: FFmpeg = null 13 | 14 | async load(ffmpeg: FFmpeg): Promise<boolean> { 15 | this.ffmpeg = ffmpeg 16 | return ffmpeg.load({ 17 | coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "application/javascript"), 18 | wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), 19 | classWorkerURL: await toBlobURL(ffmpegWorkerJs, 'text/javascript') 20 | }) 21 | } 22 | 23 | async fix(input: string, output: string, prepareCut: boolean): Promise<Cleanup> { 24 | if (!this.ffmpeg) throw new Error('FFmpeg not loaded') 25 | 26 | await this.ffmpeg.exec([ 27 | '-fflags', '+genpts+igndts', 28 | '-i', input, 29 | '-c', 'copy', 30 | ...(prepareCut ? [] : ['-r', '60']), 31 | output 32 | ]) 33 | 34 | return async () => { 35 | await this.ffmpeg.deleteFile(output) 36 | } 37 | 38 | } 39 | 40 | async cut(input: string, output: string, duration: number): Promise<Cleanup> { 41 | if (!this.ffmpeg) throw new Error('FFmpeg not loaded') 42 | 43 | const seconds = `${duration * 60}` 44 | const temp = randomString() 45 | 46 | await this.ffmpeg.exec([ 47 | '-fflags', '+genpts+igndts', 48 | '-sseof', `-${seconds}`, 49 | '-i', input, 50 | '-r', '60', 51 | '-avoid_negative_ts', 'make_zero', 52 | '-c', 'copy', 53 | temp + output 54 | ]) 55 | 56 | await this.ffmpeg.exec([ 57 | '-fflags', '+genpts+igndts', 58 | '-i', temp + output, 59 | '-t', seconds, 60 | '-c', 'copy', 61 | output 62 | ]) 63 | 64 | return async () => { 65 | await this.ffmpeg.deleteFile(temp + output) 66 | await this.ffmpeg.deleteFile(output) 67 | } 68 | } 69 | 70 | } 71 | 72 | const singleThread = new SingleThread() 73 | export default singleThread -------------------------------------------------------------------------------- /src/ffmpeg/index.ts: -------------------------------------------------------------------------------- 1 | import { FFmpeg } from "@ffmpeg/ffmpeg" 2 | import { isBackgroundScript } from "~utils/file" 3 | import coreSt from './core' 4 | import coreMt from './core-mt' 5 | 6 | export type Cleanup = () => Promise<void> 7 | 8 | export interface FFMpegCore { 9 | 10 | load(ffmpeg: FFmpeg): Promise<boolean> 11 | 12 | cut(input: string, output: string, duration: number): Promise<Cleanup> 13 | fix(input: string, output: string, prepareCut: boolean): Promise<Cleanup> 14 | } 15 | 16 | function getFFMpegCore(): FFMpegCore { 17 | return isBackgroundScript() ? coreMt : coreSt 18 | } 19 | 20 | export default getFFMpegCore -------------------------------------------------------------------------------- /src/hooks/bilibili.ts: -------------------------------------------------------------------------------- 1 | import { useMutationObserver } from '@react-hooks-library/core' 2 | import { useState } from 'react' 3 | import { type SettingSchema as DeveloperSchema } from '~options/fragments/developer' 4 | 5 | export type WebScreenStatus = 'normal' | 'web-fullscreen' | 'fullscreen' 6 | 7 | /** 8 | * Custom hook that tracks the screen status of a web page. 9 | * @param classes - The CSS classes used to identify different screen statuses. 10 | * @returns The current screen status. 11 | * @example 12 | * // Usage 13 | * const classes = { 14 | * screenWeb: 'web-screen', 15 | * screenFull: 'full-screen' 16 | * }; 17 | * const screenStatus = useWebScreenChange(classes); 18 | * console.log(screenStatus); // 'web-fullscreen', 'fullscreen', or 'normal' 19 | */ 20 | export function useWebScreenChange(classes: DeveloperSchema['classes']): WebScreenStatus { 21 | 22 | const fetchScreenStatus = (bodyElement: HTMLElement) => 23 | bodyElement.classList.contains(classes.screenWeb) ? 24 | 'web-fullscreen' : 25 | bodyElement.classList.contains(classes.screenFull) ? 26 | 'fullscreen' : 27 | 'normal' 28 | 29 | const [screenStatus, setScreenStatus] = useState<WebScreenStatus>(() => fetchScreenStatus(document.body)) 30 | 31 | useMutationObserver(document.body, (mutations: MutationRecord[]) => { 32 | if (mutations[0].type !== 'attributes') return 33 | if (!(mutations[0].target instanceof HTMLElement)) return 34 | const bodyElement = mutations[0].target 35 | 36 | const newStatus: WebScreenStatus = fetchScreenStatus(bodyElement) 37 | 38 | setScreenStatus(newStatus) 39 | 40 | }, { attributes: true, subtree: false, childList: false }) 41 | 42 | return screenStatus 43 | } -------------------------------------------------------------------------------- /src/hooks/force-update.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | /** 4 | * Custom hook that returns a state and a function to force update the component. 5 | * This hook is useful when you want to trigger a re-render of the component 6 | * without changing any of its dependencies. 7 | * 8 | * @returns An array containing the state object and the force update function. 9 | * The state object can be used as a dependency in `useEffect`, `useCallback`, 10 | * `useMemo`, etc. 11 | * 12 | * @example 13 | * ```typescript 14 | * const [forceUpdateState, forceUpdate] = useForceUpdate() 15 | * 16 | * useEffect(() => { 17 | * // This effect will be triggered whenever `forceUpdateState` changes. 18 | * // You can use this to force a re-render of the component. 19 | * }, [forceUpdateState]) 20 | * 21 | * const handleClick = useCallback(() => { 22 | * // This callback will be memoized and will only change when `forceUpdateState` changes. 23 | * // You can use this to trigger a re-render of the component. 24 | * forceUpdate() 25 | * }, [forceUpdateState]) 26 | * 27 | * const memoizedValue = useMemo(() => { 28 | * // This value will be memoized and will only change when `forceUpdateState` changes. 29 | * // You can use this to trigger a re-render of the component. 30 | * return someExpensiveComputation() 31 | * }, [forceUpdateState]) 32 | * ``` 33 | */ 34 | export function useForceUpdate(): [any, () => void] { 35 | const [deps, setDeps] = useState({}) 36 | return [ 37 | deps, 38 | () => setDeps({}) 39 | ] as const 40 | } 41 | 42 | /** 43 | * Custom hook that returns a function to force re-rendering of a component. 44 | * 45 | * @returns {() => void} The function to force re-rendering. 46 | * 47 | * @example 48 | * // Usage 49 | * const forceRender = useForceRender() 50 | * 51 | * // Call the function to force re-rendering 52 | * forceRender() 53 | */ 54 | export function useForceRender(): () => void { 55 | const [, forceUpdate] = useForceUpdate() 56 | return forceUpdate 57 | } -------------------------------------------------------------------------------- /src/hooks/form.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | 3 | /** 4 | * Custom hook to handle file input selection and processing. 5 | * 6 | * @param onFileChange - Callback function that processes the selected files. It should return a Promise. 7 | * @param onError - Optional callback function to handle errors during file processing. 8 | * @param deps - Dependency array for the `useCallback` hook. 9 | * 10 | * @returns An object containing: 11 | * - `inputRef`: A reference to the file input element. 12 | * - `selectFiles`: A function to trigger the file input dialog and handle file selection. 13 | * 14 | * @example 15 | * ```typescript 16 | * const { inputRef, selectFiles } = useFileInput( 17 | * async (files) => { 18 | * // Process the files 19 | * console.log(files); 20 | * }, 21 | * (error) => { 22 | * // Handle the error 23 | * console.error(error); 24 | * } 25 | * ); 26 | * 27 | * // To trigger file selection 28 | * selectFiles(); 29 | * ``` 30 | */ 31 | export function useFileInput(onFileChange: (files: FileList) => Promise<void>, onError?: (e: Error | any) => void, deps: any[] = []) { 32 | 33 | const inputRef = useRef<HTMLInputElement>() 34 | const selectFiles = useCallback(function (): Promise<void> { 35 | return new Promise((resolve, reject) => { 36 | const finallize = () => { 37 | inputRef.current.removeEventListener('change', listener) 38 | inputRef.current.removeEventListener('cancel', finallize) 39 | inputRef.current.files = null 40 | resolve() 41 | } 42 | const listener = async (e: Event) => { 43 | try { 44 | const files = (e.target as HTMLInputElement).files 45 | if (files.length === 0) return 46 | await onFileChange(files) 47 | } catch (e: Error | any) { 48 | console.error(e) 49 | onError?.(e) 50 | reject(e) 51 | } finally { 52 | finallize() 53 | } 54 | } 55 | inputRef.current.addEventListener('change', listener) 56 | inputRef.current.addEventListener('cancel', finallize) 57 | inputRef.current.click() 58 | }) 59 | }, deps) 60 | 61 | return { 62 | inputRef, 63 | selectFiles, 64 | } 65 | } -------------------------------------------------------------------------------- /src/hooks/loader.ts: -------------------------------------------------------------------------------- 1 | import { stateProxy } from 'react-state-proxy' 2 | 3 | export type Loaders = Record<string, () => Promise<void>> 4 | 5 | export type LoaderBinding<L extends Loaders> = [ 6 | Record<keyof L, () => Promise<void>>, 7 | Readonly<Record<keyof L, boolean>> 8 | ] 9 | 10 | 11 | /** 12 | * Custom hook that creates a loader binding for a set of loaders. 13 | * @template L - The type of the loaders object. 14 | * @param loaders - An object containing loader functions. 15 | * @param onCatch - Optional error handler function. Defaults to console.error. 16 | * @returns A tuple containing the loader functions and a loading state object. 17 | * @example 18 | * const [loader, loading] = useLoader({ 19 | * loadUsers: async () => { 20 | * // Load users 21 | * }, 22 | * loadPosts: async () => { 23 | * // Load posts 24 | * }, 25 | * }, handleError) 26 | * 27 | * // Usage 28 | * loader.loadUsers() // Start loading users 29 | * 30 | * if (loading.loadUsers) { 31 | * // Show loading indicator for users 32 | * } 33 | */ 34 | export function useLoader<L extends Loaders>(loaders: L, onCatch: (e: Error | any) => void = console.error): LoaderBinding<L> { 35 | 36 | const loading = stateProxy(Object.keys(loaders) 37 | .reduce((acc, k: keyof L) => 38 | ({ 39 | ...acc, 40 | [k]: false 41 | }) 42 | , {})) as { [key in keyof L]: boolean } 43 | 44 | const loader = Object.keys(loaders) 45 | .reduce((acc, k: keyof L) => 46 | ({ 47 | ...acc, 48 | [k]: async () => { 49 | try { 50 | loading[k] = true 51 | await loaders[k]() 52 | } catch (e: Error | any) { 53 | onCatch(e) 54 | } finally { 55 | loading[k] = false 56 | } 57 | } 58 | }) 59 | , {}) as { [key in keyof L]: () => Promise<void> } 60 | 61 | return [loader, loading] as const 62 | } 63 | 64 | -------------------------------------------------------------------------------- /src/hooks/message.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addBLiveMessageCommandListener, 3 | addBLiveMessageListener, 4 | addWindowMessageListener 5 | } from '~utils/messaging' 6 | 7 | import type { BLiveDataWild } from "~types/bilibili" 8 | import { addBLiveSubscriber } from '~utils/subscriber' 9 | import { useEffect } from 'react' 10 | 11 | /** 12 | * Custom hook that listens for window messages with a specific command and executes a handler function. 13 | * 14 | * @param command - The command string to listen for. 15 | * @param handler - The function to be executed when a matching window message is received. 16 | */ 17 | export function useWindowMessage(command: string, handler: (data: any, event: MessageEvent) => void) { 18 | useEffect(() => { 19 | const removeListener = addWindowMessageListener(command, handler) 20 | return () => removeListener() 21 | }, []) 22 | 23 | } 24 | 25 | /** 26 | * Custom hook for handling BLive messages. 27 | * 28 | * @template K - The type of the command key. 29 | * @param {function} handler - The callback function to handle the message. 30 | * @returns {void} 31 | */ 32 | export function useBLiveMessage<K extends string>(handler: (data: { cmd: K, command: BLiveDataWild<K> }, event: MessageEvent) => void) { 33 | useEffect(() => { 34 | const removeListener = addBLiveMessageListener(handler) 35 | return () => removeListener() 36 | }, []) 37 | } 38 | 39 | /** 40 | * Custom hook for handling BLive message commands. 41 | * 42 | * @template K - The type of the command. 43 | * @param {K} cmd - The command to listen for. 44 | * @param {(command: BLiveDataWild<K>, event: MessageEvent) => void} handler - The handler function to be called when the command is received. 45 | * @returns {void} 46 | */ 47 | export function useBLiveMessageCommand<K extends string>(cmd: K, handler: (command: BLiveDataWild<K>, event: MessageEvent) => void) { 48 | useEffect(() => { 49 | const removeListener = addBLiveMessageCommandListener(cmd, handler) 50 | return () => removeListener() 51 | }, []) 52 | } 53 | 54 | /** 55 | * Custom hook for subscribing to BLive messages. 56 | * 57 | * @template K - The type of the command. 58 | * @param {K} command - The command to subscribe to. 59 | * @param {(command: BLiveDataWild<K>, event: MessageEvent) => void} handler - The handler function to be called when a message is received. 60 | * @returns {void} 61 | */ 62 | export function useBLiveSubscriber<K extends string>(command: K, handler: (command: BLiveDataWild<K>, event: MessageEvent) => void) { 63 | useEffect(() => { 64 | const removeListener = addBLiveSubscriber(command, handler) 65 | return () => removeListener() 66 | }, []) 67 | } -------------------------------------------------------------------------------- /src/hooks/promise.ts: -------------------------------------------------------------------------------- 1 | import { type Reducer, useEffect, useReducer } from 'react' 2 | 3 | type State<T> = { 4 | data: T | null 5 | error: Error | null 6 | loading: boolean 7 | } 8 | 9 | type Action<T> = 10 | | { type: "LOADING" } 11 | | { type: "SUCCESS", payload: T } 12 | | { type: "ERROR", payload: Error } 13 | 14 | function reducer<T>(state: State<T>, action: Action<T>): State<T> { 15 | switch (action.type) { 16 | case "LOADING": 17 | return { ...state, loading: true } 18 | case "SUCCESS": 19 | return { data: action.payload, error: null, loading: false } 20 | case "ERROR": 21 | return { data: null, error: action.payload, loading: false } 22 | default: 23 | return state 24 | } 25 | } 26 | 27 | /** 28 | * Custom hook that handles a promise and its state. 29 | * @template T The type of data returned by the promise. 30 | * @param {Promise<T> | (() => Promise<T>)} promise The promise to be handled. 31 | * @param {any[]} [deps=[]] The dependencies array for the useEffect hook. (Only work if the promise is a function.) 32 | * @returns {[T, Error | any, boolean]} An array containing the data, error, and loading state. 33 | */ 34 | /** 35 | * Custom hook that handles a promise and returns the result, error, and loading state. 36 | * @template T The type of the promise result. 37 | * @param {Promise<T> | (() => Promise<T>)} promise The promise to be executed or a function that returns the promise. 38 | * @param {any[]} [deps=[]] The dependencies array for the useEffect hook. 39 | * @returns {[T, Error | any, boolean]} An array containing the result, error, and loading state. 40 | * 41 | * @example 42 | * const [data, error, loading] = usePromise(fetchData) 43 | * 44 | * @example <caption>With dependencies</caption> 45 | * const [data, error, loading] = usePromise(() => fetchData(id), [id]) 46 | */ 47 | export function usePromise<T>(promise: Promise<T> | (() => Promise<T>), deps: any[] = []): [T, Error | any, boolean] { 48 | const [state, dispatch] = useReducer<Reducer<State<T>, Action<T>>>(reducer, { 49 | data: null, 50 | error: null, 51 | loading: true, 52 | }) 53 | 54 | useEffect(() => { 55 | dispatch({ type: "LOADING" }); 56 | (promise instanceof Function ? promise() : promise) 57 | .then((data) => { 58 | dispatch({ type: "SUCCESS", payload: data }) 59 | }) 60 | .catch((error) => { 61 | console.warn(error) 62 | dispatch({ type: "ERROR", payload: error }) 63 | }) 64 | }, [promise, ...deps]) 65 | 66 | return [state.data, state.error, state.loading] as const 67 | } 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/hooks/storage.ts: -------------------------------------------------------------------------------- 1 | import { useStorage as useStorageApi } from '@plasmohq/storage/hook' 2 | import { useState, useEffect } from 'react' 3 | import { Storage, type StorageCallbackMap } from '@plasmohq/storage' 4 | import { storage } from '~utils/storage' 5 | 6 | type Setter<T> = ((v?: T, isHydrated?: boolean) => T) | T 7 | 8 | export const useStorage = <T extends object>(key: string, onInit?: Setter<T>) => useStorageApi<T>({ key, instance: storage }, onInit) 9 | 10 | 11 | /** 12 | * Custom hook for watching changes in browser storage and returning the watched value. 13 | * 14 | * @template T - The type of the value stored in the storage. 15 | * @param {string} key - The key used to store the value in the storage. 16 | * @param {"sync" | "local" | "managed" | "session"} area - The storage area to watch for changes. 17 | * @param {T} [defaultValue] - The default value to be returned if the value is not found in the storage. 18 | * @returns {T} - The watched value from the storage. 19 | * 20 | * @example 21 | * // Watching changes in "sync" storage for the key "myKey" with a default value of 0 22 | * const watchedValue = useStorageWatch<number>("myKey", "sync", 0); 23 | */ 24 | export function useStorageWatch<T = any>(key: string, area: "sync" | "local" | "managed" | "session", defaultValue?: T): T { 25 | const storage = new Storage({ area }) 26 | const [watchedValue, setWatchedValue] = useState(defaultValue) 27 | const watchCallback: StorageCallbackMap = { 28 | [key]: (value, ar) => ar === area && setWatchedValue(value.newValue) 29 | } 30 | useEffect(() => { 31 | storage.get<T>(key) 32 | .then(value => setWatchedValue(value)) 33 | .catch(() => console.error(`Failed to get ${key} from ${area} storage`)) 34 | storage.watch(watchCallback) 35 | return () => { 36 | storage.unwatch(watchCallback) 37 | } 38 | }, []) 39 | return watchedValue 40 | } -------------------------------------------------------------------------------- /src/hooks/styles.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | 4 | /** 5 | * Hook to get the computed style of a given DOM element. 6 | * 7 | * @param {Element} element - The DOM element to get the computed style for. 8 | * @returns {CSSStyleDeclaration} The computed style of the given element. 9 | * 10 | * @example 11 | * ```typescript 12 | * const element = document.getElementById('myElement'); 13 | * const computedStyle = useComputedStyle(element); 14 | * console.log(computedStyle.color); // Outputs the color style of the element 15 | * ``` 16 | */ 17 | export function useComputedStyle(element: Element): CSSStyleDeclaration { 18 | return useMemo(() => element ? window.getComputedStyle(element) : {} as CSSStyleDeclaration, [element]); 19 | } 20 | 21 | /** 22 | * Calculates the contrast of a given background element and returns an object 23 | * containing the brightness, appropriate text color, and a boolean indicating 24 | * if the background is dark. 25 | * 26 | * @param {Element} background - The background element to compute the contrast for. 27 | * @returns {Object} An object containing: 28 | * - `brightness` {number} - The brightness value of the background color. 29 | * - `color` {string} - The text color that contrasts with the background ('black' or 'white'). 30 | * - `dark` {boolean} - A boolean indicating if the background is dark (true if brightness > 125). 31 | * 32 | * @example 33 | * const backgroundElement = document.getElementById('myElement'); 34 | * const contrast = useContrast(backgroundElement); 35 | * console.log(contrast.brightness); // e.g., 150 36 | * console.log(contrast.color); // 'black' 37 | * console.log(contrast.dark); // true 38 | */ 39 | export function useContrast(background: Element) { 40 | const { backgroundColor: rgb } = useComputedStyle(background); 41 | return useMemo(() => { 42 | const r = parseInt(rgb.slice(4, rgb.indexOf(','))); 43 | const g = parseInt(rgb.slice(rgb.indexOf(',', rgb.indexOf(',') + 1))); 44 | const b = parseInt(rgb.slice(rgb.lastIndexOf(',') + 1, -1)); 45 | const brightness = (r * 299 + g * 587 + b * 114) / 1000; 46 | return { 47 | brightness, 48 | color: brightness > 125 ? 'black' : 'white', 49 | dark: brightness > 125 50 | }; 51 | }, [rgb]); 52 | } -------------------------------------------------------------------------------- /src/hooks/teleport.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from 'react' 2 | import { createPortal } from 'react-dom' 3 | 4 | export type TeleportSettings<T> = { 5 | parentQuerySelector: string 6 | id: string 7 | placement: (parent: Element, child: Element) => void 8 | shouldPlace: (data: T) => boolean 9 | } 10 | 11 | 12 | /** 13 | * Custom hook for teleporting a component to a different location in the DOM. 14 | * @template T - The type of the state object. 15 | * @param {T} state - The state object. 16 | * @param {TeleportSettings<T>} settings - The settings for the teleportation. 17 | * @returns {{ Teleport: JSX.Element, rootContainer: HTMLElement | null }} - The Teleport component and the root container element. 18 | * 19 | * @example 20 | * const state = { isVisible: true }; 21 | * const settings = { 22 | * id: 'teleport-container', 23 | * parentQuerySelector: '#app', 24 | * shouldPlace: (state) => state.isVisible, 25 | * placement: (parent, child) => parent.appendChild(child) 26 | * }; 27 | * const { Teleport, rootContainer } = useTeleport(state, settings); 28 | * 29 | * // Usage of the Teleport component 30 | * <Teleport container={rootContainer} > 31 | * <div>Teleported content</div> 32 | * </Teleport> 33 | */ 34 | export function useTeleport<T>(state: T, settings: TeleportSettings<T>) { 35 | 36 | // dont forget to remove the root container when unmount 37 | useEffect(() => { 38 | return () => document.getElementById(settings.id)?.remove() 39 | }, []) 40 | 41 | const rootContainer = useMemo(() => { 42 | const parentElement = document.querySelector(settings.parentQuerySelector) 43 | if (!parentElement) { 44 | console.warn(`找不到父元素,請檢查 parentQuerySelector: ${settings.parentQuerySelector}`) 45 | } 46 | let childElement = document.getElementById(settings.id) 47 | if (!settings.shouldPlace(state)) { 48 | childElement?.remove() 49 | childElement = null 50 | } else if (parentElement && childElement === null) { 51 | childElement = document.createElement('div') 52 | childElement.id = settings.id 53 | settings.placement(parentElement, childElement) 54 | } 55 | return childElement 56 | }, [state]) 57 | 58 | if (settings.shouldPlace(state) && rootContainer === null) { 59 | console.warn(`找不到子元素,請檢查 id: ${settings.id}`) 60 | } 61 | 62 | return { Teleport, rootContainer } 63 | 64 | } 65 | 66 | const Teleport = ({ children, container }: { children: JSX.Element, container?: Element }): React.ReactNode => { 67 | return container ? createPortal(children, container) : children 68 | } 69 | -------------------------------------------------------------------------------- /src/hooks/window.ts: -------------------------------------------------------------------------------- 1 | import { sendMessager } from "~utils/messaging" 2 | import { useCallback } from "react" 3 | 4 | export type PopupCreateInfo = Omit<chrome.windows.CreateData, 'url'> 5 | 6 | /** 7 | * Custom hook for creating a popup window or opening a new tab/window. 8 | * @param enabledPip - Flag indicating whether picture-in-picture mode is enabled. 9 | * @param options - chrome window create configuration options. 10 | * @returns An object containing the createPopupWindow function and the pipSupported flag. 11 | * @example 12 | * const { createPopupWindow, pipSupported } = usePopupWindow(true, { width: 800, height: 600 }); 13 | * createPopupWindow('https://example.com', { param1: 'value1', param2: 'value2' }); 14 | */ 15 | export function usePopupWindow(enabledPip: boolean, options: PopupCreateInfo) { 16 | const pipSupported = window.documentPictureInPicture !== undefined 17 | const createPopupWindow = useCallback((tabUrl: string, params: Record<string, string> = {}) => { 18 | const url = chrome.runtime.getURL(`/tabs/${tabUrl}?${new URLSearchParams(params).toString()}`) 19 | return async function (e: React.MouseEvent<Element>) { 20 | e.preventDefault() 21 | if (enabledPip && e.ctrlKey) { 22 | if (!pipSupported) { 23 | alert('你的浏览器不支持自定义元素的画中画') 24 | return 25 | } 26 | const size: RequestPipOptions = options.width || options.height ? { width: options.width ?? 500, height: options.height ?? 800 } : undefined 27 | const pip = await window.documentPictureInPicture.requestWindow(size) 28 | const iframe = document.createElement('iframe') 29 | iframe.src = url 30 | iframe.style.width = '100%' 31 | iframe.style.height = '100%' 32 | iframe.height = options.height?.toString() 33 | iframe.width = options.width?.toString() 34 | iframe.allow = 'autoplay; fullscreen' 35 | iframe.frameBorder = '0' 36 | iframe.allowFullscreen = true 37 | iframe.mozallowfullscreen = true 38 | iframe.msallowfullscreen = true 39 | iframe.oallowfullscreen = true 40 | iframe.webkitallowfullscreen = true 41 | pip.document.body.style.margin = '0' // I dunno why but default is 8px 42 | pip.document.body.style.overflow = 'hidden' 43 | pip.document.body.appendChild(iframe) 44 | return 45 | } else { 46 | await sendMessager('open-window', { 47 | url, 48 | ...options 49 | }) 50 | } 51 | 52 | } 53 | 54 | }, [enabledPip, options]) 55 | 56 | return { createPopupWindow, pipSupported } 57 | } -------------------------------------------------------------------------------- /src/llms/cloudflare-ai.ts: -------------------------------------------------------------------------------- 1 | import { runAI, runAIStream, validateAIToken } from "~api/cloudflare"; 2 | import type { LLMEvent, LLMProviders, Session } from "~llms"; 3 | import type { SettingSchema } from "~options/fragments/llm"; 4 | 5 | export default class CloudFlareAI implements LLMProviders { 6 | 7 | private static readonly DEFAULT_MODEL: string = '@cf/qwen/qwen1.5-14b-chat-awq' 8 | 9 | private readonly accountId: string 10 | private readonly apiToken: string 11 | 12 | private readonly model: string 13 | 14 | constructor(settings: SettingSchema) { 15 | this.accountId = settings.accountId 16 | this.apiToken = settings.apiToken 17 | 18 | // only text generation model for now 19 | this.model = settings.model || CloudFlareAI.DEFAULT_MODEL 20 | } 21 | 22 | // mot support progress 23 | on<E extends keyof LLMEvent>(event: E, listener: LLMEvent[E]): void {} 24 | 25 | cumulative: boolean = true 26 | 27 | async validate(): Promise<void> { 28 | const success = await validateAIToken(this.accountId, this.apiToken, this.model) 29 | if (typeof success === 'boolean' && !success) throw new Error('Cloudflare API 验证失败') 30 | if (typeof success === 'string') throw new Error(success) 31 | } 32 | 33 | async prompt(chat: string): Promise<string> { 34 | const res = await runAI(this.wrap(chat), { token: this.apiToken, account: this.accountId, model: this.model }) 35 | if (!res.result) throw new Error(res.errors.join(', ')) 36 | return res.result.response 37 | } 38 | 39 | async *promptStream(chat: string): AsyncGenerator<string> { 40 | return runAIStream(this.wrap(chat), { token: this.apiToken, account: this.accountId, model: this.model }) 41 | } 42 | 43 | async asSession(): Promise<Session<LLMProviders>> { 44 | console.warn('Cloudflare AI session is not supported') 45 | return { 46 | ...this, 47 | [Symbol.asyncDispose]: async () => { } 48 | } 49 | } 50 | 51 | // text generation model input schema 52 | // so only text generation model for now 53 | private wrap(chat: string): any { 54 | return { 55 | max_tokens: 512, 56 | prompt: chat, 57 | temperature: 0.2 58 | } 59 | } 60 | 61 | 62 | } -------------------------------------------------------------------------------- /src/llms/index.ts: -------------------------------------------------------------------------------- 1 | import type { SettingSchema as LLMSchema } from '~options/fragments/llm' 2 | 3 | import cloudflare from './cloudflare-ai' 4 | import nano from './gemini-nano' 5 | import worker from './remote-worker' 6 | import webllm from './web-llm' 7 | 8 | export type LLMEvent = { 9 | progress: (p: number, t: string) => void 10 | } 11 | 12 | export interface LLMProviders { 13 | cumulative: boolean 14 | on<E extends keyof LLMEvent>(event: E, listener: LLMEvent[E]): void 15 | validate(): Promise<void> 16 | prompt(chat: string): Promise<string> 17 | promptStream(chat: string): AsyncGenerator<string> 18 | asSession(): Promise<Session<LLMProviders>> 19 | } 20 | 21 | export type Session<T extends LLMProviders> = AsyncDisposable & Pick<T, 'prompt' | 'promptStream'> 22 | 23 | const llms = { 24 | cloudflare, 25 | nano, 26 | worker, 27 | webllm 28 | } 29 | 30 | export type LLMs = typeof llms 31 | 32 | export type LLMTypes = keyof LLMs 33 | 34 | function createLLMProvider(settings: LLMSchema): LLMProviders { 35 | const type = settings.provider 36 | const LLM = llms[type] 37 | return new LLM(settings) 38 | } 39 | 40 | export default createLLMProvider -------------------------------------------------------------------------------- /src/llms/models.ts: -------------------------------------------------------------------------------- 1 | import { prebuiltAppConfig } from "@mlc-ai/web-llm" 2 | import type { LLMTypes } from "~llms" 3 | 4 | export type ModelList = { 5 | providers: LLMTypes[] 6 | models: string[] 7 | } 8 | 9 | const models: ModelList[] = [ 10 | { 11 | providers: ['worker', 'cloudflare'], 12 | models: [ 13 | '@cf/qwen/qwen1.5-14b-chat-awq', 14 | '@cf/qwen/qwen1.5-7b-chat-awq', 15 | '@cf/qwen/qwen1.5-1.8b-chat', 16 | '@hf/google/gemma-7b-it', 17 | '@hf/nousresearch/hermes-2-pro-mistral-7b' 18 | ] 19 | }, 20 | { 21 | providers: [ 'webllm' ], 22 | models: prebuiltAppConfig.model_list.map(m => m.model_id) 23 | } 24 | ] 25 | 26 | 27 | export default models -------------------------------------------------------------------------------- /src/llms/remote-worker.ts: -------------------------------------------------------------------------------- 1 | import type { LLMEvent, LLMProviders, Session } from "~llms"; 2 | import type { SettingSchema } from "~options/fragments/llm"; 3 | import { parseSSEResponses } from "~utils/binary"; 4 | 5 | // for my worker, so limited usage 6 | export default class RemoteWorker implements LLMProviders { 7 | 8 | private readonly model?: string 9 | 10 | constructor(settings: SettingSchema) { 11 | this.model = settings.model || undefined 12 | } 13 | 14 | cumulative: boolean = true 15 | 16 | on<E extends keyof LLMEvent>(event: E, listener: LLMEvent[E]): void {} 17 | 18 | async validate(): Promise<void> { 19 | const res = await fetch('https://llm.ericlamm.xyz/status') 20 | const json = await res.json() 21 | if (json.status !== 'working') { 22 | throw new Error('Remote worker is not working') 23 | } 24 | } 25 | 26 | async prompt(chat: string): Promise<string> { 27 | const res = await fetch('https://llm.ericlamm.xyz/', { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json' 31 | }, 32 | body: JSON.stringify({ prompt: chat, model: this.model }) 33 | }) 34 | if (!res.ok) throw new Error(await res.text()) 35 | const json = await res.json() 36 | return json.response 37 | } 38 | 39 | async *promptStream(chat: string): AsyncGenerator<string> { 40 | const res = await fetch('https://llm.ericlamm.xyz/', { 41 | method: 'POST', 42 | headers: { 43 | 'Content-Type': 'application/json' 44 | }, 45 | body: JSON.stringify({ prompt: chat, stream: true, model: this.model }) 46 | }) 47 | if (!res.ok) throw new Error(await res.text()) 48 | if (!res.body) throw new Error('Remote worker response body is not readable') 49 | const reader = res.body.getReader() 50 | for await (const response of parseSSEResponses(reader, '[DONE]')) { 51 | yield response 52 | } 53 | } 54 | 55 | async asSession(): Promise<Session<LLMProviders>> { 56 | console.warn('Remote worker session is not supported') 57 | return { 58 | ...this, 59 | [Symbol.asyncDispose]: async () => { } 60 | } 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | const debug = process.env.DEBUG || process.env.NODE_ENV !== 'production' 2 | 3 | 4 | console.info = console.info.bind(console, '[bilibili-vup-stream-enhancer]') 5 | console.warn = console.warn.bind(console, '[bilibili-vup-stream-enhancer]') 6 | console.error = console.error.bind(console, '[bilibili-vup-stream-enhancer]') 7 | console.log = console.log.bind(console, '[bilibili-vup-stream-enhancer]') 8 | console.debug = debug ? console.debug.bind(console, '[bilibili-vup-stream-enhancer]') : () => { } 9 | console.trace = debug ? console.trace.bind(console, '[bilibili-vup-stream-enhancer]') : () => { } -------------------------------------------------------------------------------- /src/options/components/AffixInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input, type InputProps, Typography } from '@material-tailwind/react'; 2 | import type { RefAttributes } from "react"; 3 | 4 | export type AffixInputProps = { 5 | suffix?: string 6 | prefix?: string 7 | } & RefAttributes<HTMLInputElement> & InputProps 8 | 9 | function AffixInput(props: AffixInputProps): JSX.Element { 10 | 11 | const { suffix, prefix, disabled, ...attrs } = props 12 | 13 | return ( 14 | <div className="relative flex w-full"> 15 | {prefix && 16 | <div className={`flex items-end pr-3 text-blue-gray-500 dark:text-gray-400 ${disabled ? 'opacity-50' : ''}`}> 17 | <Typography as={'span'} className="antialiased">{prefix}</Typography> 18 | </div> 19 | } 20 | <Input 21 | crossOrigin={'annoymous'} 22 | {...attrs} 23 | disabled={disabled} 24 | containerProps={{ 25 | className: "min-w-0", 26 | }} 27 | className="disabled:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed" 28 | /> 29 | {suffix && 30 | <div className={`flex items-end pl-3 text-blue-gray-500 dark:text-gray-400 ${disabled ? 'opacity-50' : ''}`}> 31 | <Typography as={'span'} className="antialiased">{suffix}</Typography> 32 | </div> 33 | } 34 | </div> 35 | ) 36 | } 37 | 38 | export default AffixInput -------------------------------------------------------------------------------- /src/options/components/CheckBoxListItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Checkbox, ListItem, ListItemPrefix, ListItemSuffix, Tooltip, Typography 3 | } from '@material-tailwind/react'; 4 | import type { ChangeEventHandler } from "react"; 5 | 6 | 7 | export type CheckboxListItemProps = { 8 | onChange?: ChangeEventHandler<HTMLInputElement> 9 | suffix?: React.ReactNode 10 | value: boolean 11 | label: string 12 | hint?: string 13 | } 14 | 15 | function CheckBoxListItem(props: CheckboxListItemProps): JSX.Element { 16 | return ( 17 | <ListItem className="p-0 dark:hover:bg-gray-800 dark:focus:bg-gray-800"> 18 | <label className="flex w-full cursor-pointer items-center px-3 py-2"> 19 | <ListItemPrefix className="mr-3"> 20 | <Checkbox 21 | data-testid={props['data-testid']} 22 | onChange={props.onChange} 23 | checked={props.value} 24 | crossOrigin={'annoymous'} 25 | ripple={false} 26 | className="hover:before:opacity-0" 27 | containerProps={{ 28 | className: "p-0", 29 | }} 30 | /> 31 | </ListItemPrefix> 32 | <Typography className="font-medium"> 33 | {props.label} 34 | </Typography> 35 | {props.hint && <Tooltip content={props.hint}> 36 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5 text-sm dark:stroke-white"> 37 | <path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" /> 38 | </svg> 39 | </Tooltip>} 40 | {props.suffix && ( 41 | <ListItemSuffix> 42 | {props.suffix} 43 | </ListItemSuffix> 44 | )} 45 | </label> 46 | </ListItem> 47 | ) 48 | } 49 | 50 | 51 | 52 | export default CheckBoxListItem -------------------------------------------------------------------------------- /src/options/components/ColorInput.tsx: -------------------------------------------------------------------------------- 1 | import type { RefAttributes } from 'react'; 2 | import { Input, type InputProps } from '@material-tailwind/react'; 3 | import type { HexColor } from "~types/common"; 4 | 5 | 6 | export type ColorInputProps = { 7 | value: HexColor 8 | optional?: boolean 9 | } & RefAttributes<HTMLInputElement> & Omit<InputProps, 'value' | 'required' | 'pattern' | 'error' | 'type' | 'optional'> 10 | 11 | 12 | function ColorInput(props: ColorInputProps): JSX.Element { 13 | 14 | const { optional: opt, value = '', ...attrs } = props 15 | const optional = opt ?? false 16 | 17 | return ( 18 | <div className="w-full flex justify-center"> 19 | <div className="relative flex w-full"> 20 | <Input 21 | variant="static" 22 | crossOrigin={'annoymous'} 23 | type="text" 24 | required={!optional} 25 | pattern="^#[A-Fa-f0-9]{6}$" 26 | maxLength={7} 27 | value={value} 28 | error={!/^#[A-Fa-f0-9]{6}$/.test(value) && (!optional === !value)} 29 | className="pr-20 font-mono tracking-[0.2rem] font-medium disabled:bg-transparent disabled:opacity-50 disabled:cursor-not-allowed" 30 | containerProps={{ 31 | className: "min-w-0", 32 | }} 33 | {...attrs} 34 | /> 35 | {value && <input type="color" disabled={attrs.disabled} required={!optional} className="!absolute right-0 bottom-0 h-8 rounded bg-transparent cursor-crosshair disabled:opacity-50 disabled:cursor-not-allowed" value={value} onChange={props.onChange} onBlur={props.onBlur} />} 36 | </div> 37 | </div> 38 | ) 39 | } 40 | 41 | 42 | export default ColorInput -------------------------------------------------------------------------------- /src/options/components/DeleteIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | const DeleteIcon: React.FC<{}> = () => ( 3 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6 dark:stroke-white"> 4 | <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> 5 | </svg> 6 | ) 7 | 8 | 9 | export default DeleteIcon -------------------------------------------------------------------------------- /src/options/components/Expander.tsx: -------------------------------------------------------------------------------- 1 | import { Collapse } from "@material-tailwind/react" 2 | import { useToggle } from "@react-hooks-library/core" 3 | import { Fragment } from "react" 4 | 5 | export type ExpanderProps = { 6 | title: string 7 | expanded?: boolean 8 | toggle?: VoidFunction 9 | prefix?: React.ReactNode 10 | colorClass?: string 11 | hoverColorClass?: string 12 | children: React.ReactNode 13 | } 14 | 15 | function Expander(props: ExpanderProps): JSX.Element { 16 | 17 | const color = props.colorClass ?? 'bg-gray-300 dark:bg-gray-800' 18 | const hoverColor = props.hoverColorClass ?? 'hover:bg-gray-400 dark:hover:bg-gray-900' 19 | const { toggle: internalToggle, bool: internalExpanded } = useToggle() 20 | const { title, expanded, toggle, children, prefix } = props 21 | 22 | return ( 23 | <Fragment> 24 | <div onClick={toggle ?? internalToggle} className={` 25 | cursor-pointer border border-[#d1d5db] dark:border-[#4b4b4b6c] px-5 py-3 ${color} w-full text-lg ${expanded ? '' : 'shadow-lg'} 26 | ${(expanded ?? internalExpanded) ? 'rounded-t-lg border-b-0' : 'rounded-lg'} ${hoverColor}`}> 27 | <div className="flex items-center gap-3 dark:text-white"> 28 | {prefix} 29 | <span>{title}</span> 30 | </div> 31 | </div> 32 | <Collapse open={expanded ?? internalExpanded}> 33 | {children} 34 | </Collapse> 35 | </Fragment> 36 | ) 37 | } 38 | 39 | export default Expander -------------------------------------------------------------------------------- /src/options/components/ExperientmentFeatureIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "@material-tailwind/react"; 2 | 3 | function ExperienmentFeatureIcon(): JSX.Element { 4 | return ( 5 | <Tooltip content="测试阶段, 可能会出现BUG或未知问题, 届时请到 github 回报。"> 6 | <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"> 7 | <path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" /> 8 | </svg> 9 | </Tooltip> 10 | ) 11 | } 12 | 13 | export default ExperienmentFeatureIcon -------------------------------------------------------------------------------- /src/options/components/FeatureRoomTable.tsx: -------------------------------------------------------------------------------- 1 | import { Switch, Typography } from "@material-tailwind/react"; 2 | import type { TableAction, TableHeader } from "./DataTable"; 3 | 4 | import { toast } from "sonner/dist"; 5 | import type { FeatureType } from "~features"; 6 | import type { RoomList } from "~types/common"; 7 | import { removeArr } from "~utils/misc"; 8 | import DataTable from "./DataTable"; 9 | import DeleteIcon from "./DeleteIcon"; 10 | 11 | const roomListHeaders: TableHeader<{ room: string, date: string }>[] = [ 12 | { 13 | name: '房间号', 14 | value: 'room' 15 | }, 16 | { 17 | name: '添加时间', 18 | value: 'date', 19 | align: 'center' 20 | } 21 | ] 22 | 23 | 24 | 25 | export type FeatureRoomTableProps = { 26 | title?: string 27 | feature: FeatureType, 28 | roomList: Record<FeatureType, RoomList>, 29 | actions?: TableAction<{ room: string, date: string }>[] 30 | } 31 | 32 | function FeatureRoomTable(props: FeatureRoomTableProps): JSX.Element { 33 | 34 | const { roomList, feature, title, actions } = props 35 | 36 | return ( 37 | <DataTable 38 | data-testid={`${feature}-whitelist-rooms`} 39 | title={title ?? '房间白名单(无数据时不生效)'} 40 | headers={roomListHeaders} 41 | values={roomList[feature].list} 42 | onAdd={room => { 43 | if (roomList[feature].list.some(e => e.room === room)) { 44 | toast.error(`房间 ${room} 已经在列表中`) 45 | return 46 | } 47 | roomList[feature].list.push({ room, date: new Date().toLocaleDateString() }) 48 | }} 49 | headerSlot={ 50 | <Switch 51 | data-testid={`${feature}-whitelist-rooms-switcher`} 52 | checked={roomList[feature].asBlackList} 53 | onChange={e => roomList[feature].asBlackList = e.target.checked} 54 | crossOrigin={'annoymous'} 55 | label={ 56 | <Typography className="font-semibold"> 57 | 使用为黑名单 58 | </Typography> 59 | } 60 | /> 61 | } 62 | actions={[ 63 | { 64 | label: '删除', 65 | icon: <DeleteIcon />, 66 | onClick: (e) => { 67 | const result = removeArr(roomList[feature].list, e) 68 | if (!result) { 69 | toast.error('删除失败') 70 | } 71 | } 72 | }, 73 | ...(actions ?? []) 74 | ]} 75 | /> 76 | ) 77 | } 78 | 79 | 80 | 81 | 82 | export default FeatureRoomTable -------------------------------------------------------------------------------- /src/options/components/Hints.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | 3 | import { Typography } from '@material-tailwind/react'; 4 | 5 | export type HintsProps = { 6 | values: (string | React.ReactNode)[] 7 | } 8 | 9 | 10 | function Hints(props: HintsProps): JSX.Element { 11 | const [first, ...rest] = props.values 12 | 13 | if (!first) return <></> 14 | 15 | return ( 16 | <Fragment> 17 | <Typography 18 | variant="small" 19 | className="mt-2 flex items-center gap-1 font-normal dark:text-gray-400" 20 | > 21 | <svg 22 | xmlns="http://www.w3.org/2000/svg" 23 | viewBox="0 0 24 24" 24 | fill="currentColor" 25 | className="-mt-px h-4 w-4" 26 | > 27 | <path 28 | fillRule="evenodd" 29 | d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z" 30 | clipRule="evenodd" 31 | /> 32 | </svg> 33 | {first} 34 | </Typography> 35 | {rest.map((hint, i) => (<Typography key={i} variant="small" className="font-normal pl-5 dark:text-gray-400">{hint}</Typography>))} 36 | </Fragment> 37 | ) 38 | } 39 | 40 | 41 | export default Hints -------------------------------------------------------------------------------- /src/options/components/SwitchListItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ListItem, 3 | ListItemPrefix, 4 | ListItemSuffix, 5 | Switch, 6 | Typography 7 | } from '@material-tailwind/react'; 8 | 9 | import type { ChangeEventHandler } from "react"; 10 | import type { colors } from "@material-tailwind/react/types/generic"; 11 | 12 | export type SwitchListItemProps = { 13 | onChange?: ChangeEventHandler<HTMLInputElement> 14 | prefix?: React.ReactNode 15 | affix?: React.ReactNode 16 | suffix?: React.ReactNode 17 | marker?: React.ReactNode 18 | value: boolean 19 | label: string | ((b: boolean) => string) 20 | hint?: string 21 | color?: colors 22 | disabled?: boolean 23 | } 24 | 25 | 26 | 27 | 28 | function SwitchListItem(props: SwitchListItemProps): JSX.Element { 29 | return ( 30 | <ListItem className="p-0 dark:hover:bg-gray-800 dark:focus:bg-gray-800" disabled={props.disabled}> 31 | <label className="flex w-full cursor-pointer items-center px-3 py-2"> 32 | {props.prefix && ( 33 | <ListItemPrefix> 34 | {props.prefix} 35 | </ListItemPrefix> 36 | )} 37 | <Switch 38 | data-testid={props['data-testid']} 39 | disabled={props.disabled} 40 | onChange={props.onChange} 41 | crossOrigin={'annoymous'} 42 | checked={props.value} 43 | ripple={false} 44 | color={props.color} 45 | label={ 46 | <div className='flex justify-between items-center gap-3'> 47 | <div> 48 | <Typography className="font-medium ml-3 flex items-center gap-2"> 49 | {props.label instanceof Function ? props.label(props.value) : props.label} 50 | {props.marker} 51 | </Typography> 52 | {props.hint && <Typography variant="small" className="font-normal ml-3"> 53 | {props.hint} 54 | </Typography>} 55 | </div> 56 | {props.affix ? ( 57 | <div> 58 | {props.affix} 59 | </div> 60 | ) : <></>} 61 | </div> 62 | } 63 | containerProps={{ 64 | className: "flex items-center p-0", 65 | }} 66 | /> 67 | {props.suffix && ( 68 | <ListItemSuffix> 69 | {props.suffix} 70 | </ListItemSuffix> 71 | )} 72 | </label> 73 | </ListItem> 74 | ) 75 | } 76 | 77 | 78 | export default SwitchListItem -------------------------------------------------------------------------------- /src/options/features/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as jimaku from './jimaku' 3 | import * as superchat from './superchat' 4 | import * as recorder from './recorder' 5 | 6 | import type { FeatureType } from '~features' 7 | import type { TableType } from "~database" 8 | import type { StateProxy } from "~hooks/binding" 9 | 10 | export type FeatureSettingsDefinition = { 11 | offlineTable: TableType | false 12 | } 13 | 14 | export type FeatureSettingSchema<T> = T extends FeatureFragment<infer U> ? U : never 15 | 16 | export interface FeatureFragment<T extends object> { 17 | title: string 18 | define: FeatureSettingsDefinition 19 | default?: React.FC<StateProxy<T>>, 20 | defaultSettings: Readonly<T> 21 | } 22 | 23 | export type FeatureSettings = typeof featureSettings 24 | 25 | const featureSettings = { 26 | jimaku, 27 | superchat, 28 | recorder 29 | } 30 | 31 | export default (featureSettings as { [K in FeatureType]: FeatureSettings[K] }) 32 | 33 | export const featureTypes: FeatureType[] = Object.keys(featureSettings) as FeatureType[] -------------------------------------------------------------------------------- /src/options/features/jimaku/components/AIFragment.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "@material-tailwind/react" 2 | import { type ChangeEvent, Fragment } from "react" 3 | import type { StateProxy } from "~hooks/binding" 4 | import ExperienmentFeatureIcon from "~options/components/ExperientmentFeatureIcon" 5 | import SwitchListItem from "~options/components/SwitchListItem" 6 | 7 | export type AISchema = { 8 | summarizeEnabled: boolean 9 | } 10 | 11 | 12 | export const aiDefaultSettings: Readonly<AISchema> = { 13 | summarizeEnabled: false 14 | } 15 | 16 | 17 | function AIFragment({ state, useHandler }: StateProxy<AISchema>): JSX.Element { 18 | 19 | const checker = useHandler<ChangeEvent<HTMLInputElement>, boolean>((e) => e.target.checked) 20 | 21 | return ( 22 | <Fragment> 23 | <List className="col-span-2 border border-[#808080] rounded-md"> 24 | <SwitchListItem 25 | data-testid="ai-enabled" 26 | label="启用同传字幕AI总结" 27 | hint="此功能将采用大语言模型对同传字幕进行总结" 28 | value={state.summarizeEnabled} 29 | onChange={checker('summarizeEnabled')} 30 | marker={<ExperienmentFeatureIcon />} 31 | /> 32 | </List> 33 | </Fragment> 34 | ) 35 | } 36 | 37 | export default AIFragment -------------------------------------------------------------------------------- /src/options/features/jimaku/components/ButtonFragment.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { type ChangeEvent, Fragment } from 'react'; 3 | import ColorInput from '~options/components/ColorInput'; 4 | 5 | import type { StateProxy } from "~hooks/binding"; 6 | import type { HexColor } from "~types/common"; 7 | 8 | export type ButtonSchema = { 9 | textColor: HexColor 10 | backgroundColor: HexColor 11 | backgroundListColor: HexColor 12 | } 13 | 14 | export const buttonDefaultSettings: Readonly<ButtonSchema> = { 15 | textColor: '#ffffff', 16 | backgroundColor: '#000000', 17 | backgroundListColor: '#ffffff' 18 | } 19 | 20 | function ButtonFragment({ state, useHandler }: StateProxy<ButtonSchema>): JSX.Element { 21 | 22 | const handler = useHandler<ChangeEvent<HTMLInputElement>, string>((e) => e.target.value) 23 | 24 | return ( 25 | <Fragment> 26 | <ColorInput data-testid="btn-bg-color" label="按钮背景颜色" value={state.backgroundColor} onChange={handler('backgroundColor')} /> 27 | <ColorInput data-testid="btn-list-color" label="按钮列表背景颜色" value={state.backgroundListColor} onChange={handler('backgroundListColor')} /> 28 | <ColorInput data-testid="btn-txt-color" label="按钮文字颜色" value={state.textColor} onChange={handler('textColor')} /> 29 | </Fragment> 30 | ) 31 | } 32 | 33 | export default ButtonFragment -------------------------------------------------------------------------------- /src/options/features/superchat/index.tsx: -------------------------------------------------------------------------------- 1 | import { Switch, Typography } from "@material-tailwind/react" 2 | import { Fragment, type ChangeEvent } from "react" 3 | import type { StateProxy } from "~hooks/binding" 4 | import ColorInput from "~options/components/ColorInput" 5 | import type { HexColor } from "~types/common" 6 | import type { FeatureSettingsDefinition } from ".." 7 | 8 | export const title: string = '醒目留言' 9 | 10 | export const define: FeatureSettingsDefinition = { 11 | offlineTable: 'superchats' 12 | } 13 | 14 | export type FeatureSettingSchema = { 15 | floatingButtonColor: HexColor, 16 | buttonColor: HexColor, 17 | displayFullScreen: boolean 18 | } 19 | 20 | export const defaultSettings: Readonly<FeatureSettingSchema> = { 21 | floatingButtonColor: '#db7d1f', 22 | buttonColor: '#db7d1f', 23 | displayFullScreen: false 24 | } 25 | 26 | 27 | function SuperchatFeatureSettings({ state, useHandler }: StateProxy<FeatureSettingSchema>): JSX.Element { 28 | 29 | const str = useHandler<ChangeEvent<HTMLInputElement>, string>((e) => e.target.value) 30 | const bool = useHandler<ChangeEvent<HTMLInputElement>, boolean>((e) => e.target.checked) 31 | 32 | return ( 33 | <Fragment> 34 | <ColorInput data-testid="floater-color" label="浮动按钮颜色" value={state.floatingButtonColor} onChange={str('floatingButtonColor')} /> 35 | <ColorInput data-testid="operator-color" label="操作按钮颜色" value={state.buttonColor} onChange={str('buttonColor')} /> 36 | <div className="md:col-span-2 max-md:col-span-1"> 37 | <Switch 38 | crossOrigin={'annoymous'} 39 | label={ 40 | <Typography className="font-medium" >在全屏模式下显示</Typography> 41 | } 42 | checked={state.displayFullScreen} 43 | onChange={bool('displayFullScreen')} 44 | /> 45 | </div> 46 | </Fragment> 47 | ) 48 | } 49 | 50 | 51 | export default SuperchatFeatureSettings -------------------------------------------------------------------------------- /src/options/fragments.ts: -------------------------------------------------------------------------------- 1 | import type { StateProxy } from '~hooks/binding' 2 | import * as capture from './fragments/capture' 3 | import * as developer from './fragments/developer' 4 | import * as display from './fragments/display' 5 | import * as features from './fragments/features' 6 | import * as listings from './fragments/listings' 7 | import * as version from './fragments/version' 8 | import * as llm from './fragments/llm' 9 | 10 | 11 | interface SettingFragment<T extends object> { 12 | defaultSettings: Readonly<T> 13 | default: React.FC<StateProxy<T>> 14 | title: string 15 | description: string | string[] 16 | } 17 | 18 | export type SettingFragments = typeof fragments 19 | 20 | export type Schema<T> = T extends SettingFragment<infer U> ? U : never 21 | 22 | export type Settings = { 23 | [K in keyof SettingFragments]: Schema<SettingFragments[K]> 24 | } 25 | 26 | // also defined the order of the settings 27 | const fragments = { 28 | 'settings.features': features, 29 | 'settings.listings': listings, 30 | 'settings.capture': capture, 31 | 'settings.display': display, 32 | 'settings.llm': llm, 33 | 'settings.developer': developer, 34 | 'settings.version': version 35 | } 36 | 37 | export default fragments 38 | 39 | -------------------------------------------------------------------------------- /src/options/fragments/capture.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Fragment, type ChangeEvent } from 'react'; 3 | import { toast } from 'sonner/dist'; 4 | import { type StateProxy } from '~hooks/binding'; 5 | import Selector from '~options/components/Selector'; 6 | 7 | import type { AdapterType } from '~adapters'; 8 | import SwitchListItem from '~options/components/SwitchListItem'; 9 | import { List } from '@material-tailwind/react'; 10 | 11 | export type SettingSchema = { 12 | captureMechanism: AdapterType 13 | boostWebSocketHook: boolean 14 | } 15 | 16 | export const defaultSettings: Readonly<SettingSchema> = { 17 | captureMechanism: 'websocket', 18 | boostWebSocketHook: true 19 | } 20 | 21 | export const title = '捕捉机制相关' 22 | 23 | export const description = [ 24 | '此设定区块包含了一些捕捉机制相关的设定, 你可以在这里调整一些捕捉机制的设定。', 25 | '在正常情况下请尽量使用 WebSocket 挂接, 捕捉元素仅作为备用方案。' 26 | ] 27 | 28 | function CaptureSettings({ state, useHandler }: StateProxy<SettingSchema>): JSX.Element { 29 | 30 | const boolHandler = useHandler<ChangeEvent<HTMLInputElement>, boolean>(e => e.target.checked) 31 | 32 | const changeMechanism = (e: AdapterType) => { 33 | if (e === 'dom' && !window.confirm('捕捉元素功能受限, 我们仅建议在WS挂接无法运作的情况下才作为备用, 是否继续?')) return 34 | state.captureMechanism = e 35 | if (state.captureMechanism === 'dom') { 36 | toast.warning('已切换到捕捉元素机制, 以下功能将无法使用:', { 37 | description: ( 38 | <ul> 39 | <li>弹幕位置</li> 40 | <li>除了弹幕以外的所有功能</li> 41 | </ul> 42 | ), 43 | }) 44 | } 45 | } 46 | 47 | return ( 48 | <Fragment> 49 | <Selector<AdapterType> 50 | label="捕捉机制" 51 | value={state.captureMechanism} 52 | onChange={changeMechanism} 53 | options={[ 54 | { value: 'websocket', label: 'WebSocket挂接' }, 55 | { value: 'dom', label: '捕捉元素(功能受限)' }, 56 | ]} 57 | /> 58 | <List> 59 | <SwitchListItem 60 | disabled={state.captureMechanism !== 'websocket'} 61 | label="提高WebSocket挂接速度" 62 | value={state.boostWebSocketHook} 63 | onChange={boolHandler('boostWebSocketHook')} 64 | hint="启用后将大幅提高挂接速度, 但需要刷新页面方可生效。若发现未知问题请关闭此选项。" 65 | /> 66 | </List> 67 | </Fragment> 68 | ) 69 | } 70 | 71 | 72 | 73 | export default CaptureSettings -------------------------------------------------------------------------------- /src/options/fragments/display.tsx: -------------------------------------------------------------------------------- 1 | import { type ChangeEvent, Fragment } from 'react'; 2 | import CheckBoxListItem from '~options/components/CheckBoxListItem'; 3 | 4 | import { List } from '@material-tailwind/react'; 5 | 6 | import type { StateProxy } from "~hooks/binding"; 7 | export type SettingSchema = { 8 | restartButton: boolean 9 | blackListButton: boolean 10 | settingsButton: boolean 11 | themeToNormalButton: boolean 12 | supportWebFullScreen: boolean 13 | } 14 | 15 | 16 | export const defaultSettings: Readonly<SettingSchema> = { 17 | restartButton: false, 18 | blackListButton: true, 19 | settingsButton: true, 20 | themeToNormalButton: true, 21 | supportWebFullScreen: false 22 | } 23 | 24 | export const title = '界面按钮显示' 25 | 26 | export const description = `此设定区块将控制你在主菜单上所显示的按钮,你可以在这里调整各个按钮的显示状态。` 27 | 28 | function DisplaySettings({ state, useHandler }: StateProxy<SettingSchema>): JSX.Element { 29 | 30 | 31 | const checker = useHandler<ChangeEvent<HTMLInputElement>, boolean>((e) => e.target.checked) 32 | 33 | return ( 34 | <Fragment> 35 | <List className="md:col-span-2 max-md:col-span-1"> 36 | <CheckBoxListItem 37 | label="支持在网页全屏下显示" 38 | value={state.supportWebFullScreen} 39 | onChange={checker('supportWebFullScreen')} 40 | /> 41 | <CheckBoxListItem 42 | label="重新启动按钮" 43 | value={state.restartButton} 44 | onChange={checker('restartButton')} 45 | /> 46 | <CheckBoxListItem 47 | label="添加到黑名单的按钮" 48 | value={state.blackListButton} 49 | onChange={checker('blackListButton')} 50 | hint="别担心,在页面右键打开菜单仍可进行添加黑名单的动作。" 51 | /> 52 | <CheckBoxListItem 53 | label="设定按钮" 54 | value={state.settingsButton} 55 | onChange={checker('settingsButton')} 56 | hint="一般情况下,你可透过点击浏览器扩展图标来进入设定界面" 57 | /> 58 | <CheckBoxListItem 59 | label="大海报房间新增返回正常房间按钮" 60 | value={state.themeToNormalButton} 61 | onChange={checker('themeToNormalButton')} 62 | /> 63 | </List> 64 | </Fragment> 65 | ) 66 | } 67 | 68 | 69 | export default DisplaySettings -------------------------------------------------------------------------------- /src/options/shouldInit.ts: -------------------------------------------------------------------------------- 1 | import type { Settings } from "~options/fragments" 2 | import { type StreamInfo, ensureIsVtuber } from "~api/bilibili" 3 | 4 | export async function shouldInit(settings: Settings, info: StreamInfo): Promise<boolean> { 5 | 6 | const { 7 | "settings.features": features, 8 | "settings.listings": listings, 9 | "settings.developer": developer 10 | } = settings 11 | 12 | // features 13 | if (!info) { 14 | // do log 15 | console.info('無法取得直播資訊,已略過') 16 | return false 17 | } 18 | 19 | if (info.status === 'offline' && features.enabledRecording.length === 0 && !developer.extra.forceBoot) { 20 | console.info('直播為下綫狀態,且沒有啓用離綫儲存,已略過。(强制啓動為禁用)') 21 | return false 22 | } 23 | 24 | if (features.common.onlyVtuber) { 25 | 26 | if (info.uid !== '0') { 27 | await ensureIsVtuber(info) 28 | } 29 | 30 | if (!info.isVtuber) { 31 | // do log 32 | console.info('不是 VTuber, 已略過') 33 | return false 34 | } 35 | 36 | } 37 | 38 | // listings 39 | if (listings.blackListRooms.some((r) => r.room === info.room || r.room === info.shortRoom) === !listings.useAsWhiteListRooms) { 40 | console.info('房間已被列入黑名單,已略過') 41 | return false 42 | } 43 | 44 | return true; 45 | } 46 | -------------------------------------------------------------------------------- /src/players/index.ts: -------------------------------------------------------------------------------- 1 | import type { StreamUrl, StreamUrls } from '~background/messages/get-stream-urls' 2 | import flv from './flv' 3 | import hls from './hls' 4 | import type { StreamPlayer } from '~types/media' 5 | 6 | export type EventType = keyof StreamParseEvent 7 | 8 | export type StreamParseEvent = { 9 | 'loaded': {}, 10 | 'error': Error, 11 | 'buffer': ArrayBufferLike 12 | } 13 | 14 | export type EventHandler<E extends EventType> = (event: StreamParseEvent[E]) => void 15 | 16 | export type VideoInfo = { 17 | mimeType: string 18 | extension: string 19 | } 20 | 21 | export type PlayerType = keyof typeof players 22 | 23 | const players = { 24 | hls, 25 | flv 26 | } 27 | 28 | 29 | export type PlayerOptions = { 30 | type?: PlayerType 31 | codec?: 'avc' | 'hevc' 32 | } 33 | 34 | async function loopStreams(urls: StreamUrls, handler: (p: StreamPlayer, url: StreamUrl) => Promise<void>, options: PlayerOptions = { codec: 'avc' }) { 35 | const availables = urls 36 | .filter(url => options.type ? url.type === options.type : true) 37 | .filter(url => options.codec ? url.codec === options.codec : true) 38 | if (availables.length === 0) throw new Error('没有可用的视频流URL') 39 | for (const url of availables) { 40 | const Player = players[url.type] 41 | const player = new Player() 42 | console.info(`trying to use type ${url.type} player to load: `, url.url, ' quality: ', url.quality, ' codec: ', url.codec) 43 | if (!player.isSupported) { 44 | console.warn(`Player ${url.type} is not supported, skipped: `, url) 45 | continue 46 | } 47 | try { 48 | await handler(player, url) 49 | return player 50 | } catch (err: Error | any) { 51 | console.error(`Player failed to load: `, err, ', from: ', url) 52 | continue 53 | } 54 | } 55 | throw new Error('没有可用的播放器支援 ' + JSON.stringify(options)) 56 | } 57 | 58 | export async function loadStream(urls: StreamUrls, video: HTMLVideoElement, options: PlayerOptions = { codec: 'avc' }): Promise<StreamPlayer> { 59 | return loopStreams( 60 | urls, 61 | (p, url) => p.play(url.url, video), 62 | options 63 | ) 64 | } 65 | 66 | export async function recordStream(urls: StreamUrls, handler: EventHandler<'buffer'>, options: PlayerOptions = { codec: 'avc' }): Promise<StreamPlayer> { 67 | return loopStreams( 68 | urls, 69 | async (p, url) => { 70 | p.on('buffer', handler) 71 | await p.play(url.url) 72 | }, 73 | options 74 | ) 75 | } 76 | 77 | export default loadStream -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer base { 8 | ul, ol, menu { 9 | list-style: revert; 10 | } 11 | * { 12 | font-family: Microsoft JhengHei; 13 | } 14 | } 15 | 16 | 17 | @media (prefers-color-scheme: dark) { 18 | body { 19 | background-color: #1a1a1a; 20 | } 21 | } 22 | 23 | @keyframes top { 24 | from { 25 | transform: translateY(-30px); 26 | opacity: 0; 27 | } 28 | } 29 | 30 | @keyframes left { 31 | from { 32 | transform: translatex(-50px); 33 | opacity: 0; 34 | } 35 | } 36 | 37 | @keyframes size { 38 | from { 39 | font-size: 5px; 40 | opacity: 0; 41 | } 42 | } 43 | 44 | .bjf-scrollbar::-webkit-scrollbar { 45 | width: 5px; 46 | } 47 | 48 | .bjf-scrollbar::-webkit-scrollbar-track { 49 | background-color: transparent; 50 | } 51 | 52 | .bjf-scrollbar::-webkit-scrollbar-thumb { 53 | background-color: #808080; 54 | border-radius: 20px; 55 | } -------------------------------------------------------------------------------- /src/toaster.ts: -------------------------------------------------------------------------------- 1 | import { Toaster } from 'sonner/dist' 2 | import { createElement } from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | 5 | function injectToaster() { 6 | let div = document.getElementById("bjf-toaster") 7 | if (div == null) { 8 | div = document.createElement('div') 9 | div.id = "bjf-toaster" 10 | document.body.appendChild(div) 11 | } 12 | const root = createRoot(div) 13 | root.render(createElement(Toaster, { richColors: true, position: 'bottom-center' })) 14 | } 15 | 16 | export default injectToaster -------------------------------------------------------------------------------- /src/types/bilibili/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './room-info' 2 | export * from './room-init' 3 | export * from './spec-area-rank' 4 | export * from './stream-url' 5 | export * from './wbi-acc-info' 6 | export * from './web-interface-nav' 7 | export * from './superchat-list' 8 | 9 | export interface CommonResponse<T extends object> { 10 | code: number 11 | message: string 12 | data?: T 13 | } 14 | 15 | export interface V1Response<T extends object> extends CommonResponse<T> { 16 | ttl: number 17 | } 18 | 19 | 20 | export interface BaseResponse<T extends object> extends CommonResponse<T> { 21 | msg: string 22 | } 23 | -------------------------------------------------------------------------------- /src/types/bilibili/api/room-info.ts: -------------------------------------------------------------------------------- 1 | // generated by AI, of course 2 | 3 | export interface GetInfoByRoomResponse { 4 | room_info: RoomInfoResponse 5 | anchor_info: AnchorInfoResponse 6 | } 7 | 8 | export interface AnchorInfoResponse { 9 | base_info: { 10 | uname: string 11 | face: string 12 | gender: string 13 | official_info: { 14 | role: number 15 | title: string 16 | desc: string 17 | is_nft: number 18 | nft_dmark: string 19 | } 20 | } 21 | live_info: { 22 | level: number 23 | level_color: number 24 | score: number 25 | upgrade_score: number 26 | current: [number, number] 27 | next: [number, number] 28 | rank: string 29 | } 30 | relation_info: { 31 | attention: number 32 | } 33 | medal_info: { 34 | medal_name: string 35 | medal_id: number 36 | fansclub: number 37 | } 38 | gift_info: { 39 | price: number 40 | price_update_time: number 41 | } 42 | } 43 | 44 | 45 | export interface RoomInfoResponse { 46 | uid: number 47 | room_id: number 48 | short_id: number 49 | title: string 50 | cover: string 51 | tags: string 52 | background: string 53 | description: string 54 | live_status: number 55 | live_start_time: number 56 | live_screen_type: number 57 | lock_status: number 58 | lock_time: number 59 | hidden_status: number 60 | hidden_time: number 61 | area_id: number 62 | area_name: string 63 | parent_area_id: number 64 | parent_area_name: string 65 | keyframe: string 66 | special_type: number 67 | up_session: string 68 | pk_status: number 69 | is_studio: boolean 70 | pendants: { 71 | frame: { 72 | name: string 73 | value: string 74 | desc: string 75 | } 76 | } 77 | on_voice_join: number 78 | online: number 79 | room_type: { 80 | [key: string]: number 81 | } 82 | sub_session_key: string 83 | live_id: number 84 | live_id_str: string 85 | official_room_id: number 86 | official_room_info: null 87 | voice_background: string 88 | } -------------------------------------------------------------------------------- /src/types/bilibili/api/room-init.ts: -------------------------------------------------------------------------------- 1 | // based on the data, generate interface schema 2 | export interface RoomInitResponse { 3 | room_id: number 4 | short_id: number 5 | uid: number 6 | need_p2p: number 7 | is_hidden: boolean 8 | is_locked: boolean 9 | is_portrait: boolean 10 | live_status: number 11 | hidden_till: number 12 | lock_till: number 13 | encrypted: boolean 14 | pwd_verified: boolean 15 | live_time: number 16 | room_shield: number 17 | is_sp: number 18 | special_type: number 19 | } -------------------------------------------------------------------------------- /src/types/bilibili/api/spec-area-rank.ts: -------------------------------------------------------------------------------- 1 | export interface SpecAreaRankResponse { 2 | list: { 3 | uid: number 4 | rank: number 5 | score: number 6 | uname: string 7 | face: string 8 | link: string 9 | live_status: number 10 | top5: { 11 | uid: number 12 | rank: number 13 | uname: string 14 | face: string 15 | score: number 16 | }[] 17 | rate: string 18 | tag: string 19 | }[] 20 | extra: { 21 | processing: number 22 | promotion: number 23 | } 24 | extra_text: { 25 | text: string 26 | top_text: string 27 | } 28 | info: { 29 | uid: number 30 | rank: string 31 | score: number 32 | pre_score: number 33 | is_rank: number 34 | top5: null 35 | uname: string 36 | face: string 37 | link: string 38 | live_status: number 39 | rate: string 40 | diff_top: number 41 | real_rank: number 42 | diff_promotion: number 43 | } 44 | } -------------------------------------------------------------------------------- /src/types/bilibili/api/stream-url.ts: -------------------------------------------------------------------------------- 1 | export interface StreamUrlResponse { 2 | room_id: number 3 | short_id: number 4 | uid: number 5 | is_hidden: boolean 6 | is_locked: boolean 7 | is_portrait: boolean 8 | live_status: number 9 | hidden_till: number 10 | lock_till: number 11 | encrypted: boolean 12 | pwd_verified: boolean 13 | live_time: number 14 | room_shield: number 15 | all_special_types: number[] 16 | playurl_info: { 17 | conf_json: string 18 | playurl: { 19 | cid: number 20 | g_qn_desc: { 21 | qn: number 22 | desc: string 23 | hdr_desc: string 24 | attr_desc: null | string 25 | }[] 26 | stream: { 27 | protocol_name: string 28 | format: { 29 | format_name: string 30 | codec: { 31 | codec_name: string 32 | current_qn: number 33 | accept_qn: number[] 34 | base_url: string 35 | url_info: { 36 | host: string 37 | extra: string 38 | stream_ttl: number 39 | }[] 40 | hdr_qn: null | number 41 | dolby_type: number 42 | attr_name: string 43 | }[] 44 | }[] 45 | }[] 46 | p2p_data: { 47 | p2p: boolean 48 | p2p_type: number 49 | m_p2p: boolean 50 | m_servers: null | string 51 | } 52 | dolby_qn: null | number 53 | } 54 | } 55 | official_type: number 56 | official_room_id: number 57 | } -------------------------------------------------------------------------------- /src/types/bilibili/api/superchat-list.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface SuperChatList { 3 | list?: SuperChat[]; 4 | } 5 | 6 | export interface SuperChat { 7 | id: number; 8 | uid: number; 9 | background_image: string; 10 | background_color: string; 11 | background_icon: string; 12 | background_bottom_color: string; 13 | background_price_color: string; 14 | font_color: string; 15 | price: number; 16 | rate: number; 17 | time: number; 18 | start_time: number; 19 | end_time: number; 20 | message: string; 21 | trans_mark: number; 22 | message_trans: string; 23 | ts: number; 24 | token: string; 25 | user_info: UserInfo; 26 | is_ranked: number; 27 | is_mystery: boolean; 28 | uinfo: any[]; 29 | } 30 | 31 | export interface UserInfo { 32 | uname: string; 33 | face: string; 34 | face_frame: string; 35 | guard_level: number; 36 | user_level: number; 37 | is_vip: number; 38 | is_svip: number; 39 | is_main_vip: number; 40 | } -------------------------------------------------------------------------------- /src/types/bilibili/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | export * from './live' 3 | export * from './vtb-moe' 4 | -------------------------------------------------------------------------------- /src/types/bilibili/live/danmu_msg.ts: -------------------------------------------------------------------------------- 1 | // 描述來源: https://github.com/xfgryujk/blivedm/blob/dev/blivedm/models/web.py 2 | 3 | export interface DanmuMsg { 4 | cmd: string 5 | info: [ 6 | [ 7 | number, 8 | number, // 弹幕显示模式(滚动、顶部、底部) 9 | number, // 字体尺寸 10 | number, // 颜色 11 | number, // 时间戳(毫秒) 12 | number, // 随机数,前端叫作弹幕ID,可能是去重用的 13 | number, 14 | string, // 用户ID文本的CRC32 15 | number, 16 | number, // 是否礼物弹幕(节奏风暴) 17 | number, // 右侧评论栏气泡 18 | string, 19 | number, 20 | string | object, // 弹幕类型,0文本,1表情,2语音 21 | string | object, // 表情参数 22 | { 23 | mode: number, 24 | show_player_type: number, 25 | extra: string, 26 | user: { 27 | uid: number, // 用户ID 28 | base: { 29 | name: string, // 用户名 30 | face: string, 31 | is_mystery: boolean, 32 | name_color: number // 用户名颜色 33 | }, 34 | medal: null, 35 | wealth: { 36 | level: number // 用户等级 37 | } 38 | } 39 | }, 40 | { 41 | activity_identity: string, 42 | activity_source: number, 43 | not_show: number 44 | }, 45 | ], 46 | string, // 弹幕内容 47 | [ 48 | number, // 用户ID 49 | string, // 用户名 50 | number, // 是否房管 51 | number, // 是否月费老爷 52 | number, // 是否年费老爷 53 | number, // 用户身份,用来判断是否正式会员,猜测非正式会员为5000,正式会员为10000 54 | number, // 是否绑定手机 55 | string // 用户名颜色 56 | ], 57 | [ 58 | number, // 勋章等级 59 | string, // 勋章名 60 | string, // 勋章房间主播名 61 | number, // 勋章房间ID 62 | number, // 勋章颜色 63 | string, // 特殊勋章 64 | number, 65 | number, 66 | number, 67 | number, 68 | number, 69 | number, 70 | number 71 | ], 72 | [ 73 | number, // 用户等级 74 | number, 75 | number, // 用户等级颜色 76 | string, // 用户等级排名,>50000时为'>50000' 77 | number 78 | ], 79 | [ 80 | string, // 旧头衔 81 | string // 头衔 82 | ], 83 | number, 84 | number, // 舰队类型,0非舰队,1总督,2提督,3舰长 85 | null, 86 | { 87 | ts: number, 88 | ct: string 89 | }, 90 | number, 91 | number, 92 | null, 93 | null, 94 | number, 95 | number, 96 | number[], 97 | null 98 | ] 99 | dm_v2: string 100 | } -------------------------------------------------------------------------------- /src/types/bilibili/live/index.ts: -------------------------------------------------------------------------------- 1 | import type { DanmuMsg } from './danmu_msg' 2 | import type { InteractWord } from './interact_word' 3 | import type { SuperChatMessage } from './super_chat_message' 4 | 5 | export type BLiveData = { 6 | 'DANMU_MSG': DanmuMsg, 7 | 'INTERACT_WORD': InteractWord, 8 | 'SUPER_CHAT_MESSAGE': SuperChatMessage, 9 | } 10 | 11 | 12 | export type BLiveType = keyof BLiveData 13 | export type BLiveDataWild<T = string> = T extends BLiveType ? BLiveData[T] : any 14 | 15 | 16 | export type { 17 | DanmuMsg, 18 | InteractWord, 19 | SuperChatMessage 20 | } 21 | -------------------------------------------------------------------------------- /src/types/bilibili/live/interact_word.ts: -------------------------------------------------------------------------------- 1 | export interface InteractWord { 2 | cmd: string 3 | data: { 4 | contribution: { 5 | grade: number 6 | } 7 | contribution_v2: { 8 | grade: number 9 | rank_type: string 10 | text: string 11 | } 12 | core_user_type: number 13 | dmscore: number 14 | fans_medal: { 15 | anchor_roomid: number 16 | guard_level: number 17 | icon_id: number 18 | is_lighted: number 19 | medal_color: number 20 | medal_color_border: number 21 | medal_color_end: number 22 | medal_color_start: number 23 | medal_level: number 24 | medal_name: string 25 | score: number 26 | special: string 27 | target_id: number 28 | } 29 | group_medal: null 30 | identities: number[] 31 | is_mystery: boolean 32 | is_spread: number 33 | msg_type: number 34 | privilege_type: number 35 | roomid: number 36 | score: number 37 | spread_desc: string 38 | spread_info: string 39 | tail_icon: number 40 | tail_text: string 41 | timestamp: number 42 | trigger_time: number 43 | uid: number 44 | uinfo: { 45 | base: { 46 | face: string 47 | is_mystery: boolean 48 | name: string 49 | name_color: number 50 | origin_info: { 51 | face: string 52 | name: string 53 | } 54 | risk_ctrl_info: { 55 | face: string 56 | name: string 57 | } 58 | } 59 | uid: number 60 | } 61 | uname: string 62 | uname_color: string 63 | } 64 | } -------------------------------------------------------------------------------- /src/types/bilibili/live/super_chat_message.ts: -------------------------------------------------------------------------------- 1 | export interface SuperChatMessage { 2 | cmd: string 3 | data: { 4 | background_bottom_color: string 5 | background_color: string 6 | background_color_end: string 7 | background_color_start: string 8 | background_icon: string 9 | background_image: string 10 | background_price_color: string 11 | color_point: number 12 | dmscore: number 13 | end_time: number 14 | gift: { 15 | gift_id: number 16 | gift_name: string 17 | num: number 18 | } 19 | group_medal: object 20 | id: number 21 | is_mystery: boolean 22 | is_ranked: number 23 | is_send_audit: number 24 | medal_info: object 25 | message: string 26 | message_font_color: string 27 | message_trans: string 28 | price: number 29 | rate: number 30 | start_time: number 31 | time: number 32 | token: string 33 | trans_mark: number 34 | ts: number 35 | uid: number 36 | uinfo: { 37 | base: { 38 | face: string 39 | is_mystery: boolean 40 | uname: string 41 | } 42 | } 43 | user_info: { 44 | face: string 45 | face_frame: string 46 | guard_level: number 47 | is_main_vip: number 48 | is_svip: number 49 | is_vip: number 50 | level_color: string 51 | manager: number 52 | name_color: string 53 | title: string 54 | uname: string 55 | user_level: number 56 | } 57 | } 58 | is_report: boolean 59 | msg_id: string 60 | roomid: number 61 | send_time: number 62 | } 63 | -------------------------------------------------------------------------------- /src/types/bilibili/vtb-moe.ts: -------------------------------------------------------------------------------- 1 | // Generated by AI, of course 2 | 3 | // based on above data, export an interface for this 4 | export interface VtbMoeListResponse { 5 | meta: { 6 | UUID_NAMESPACE: string 7 | linkSyntax: { 8 | [key: string]: string 9 | } 10 | timestamp: number 11 | } 12 | vtbs: VtbMoeVtbResponse[] 13 | } 14 | 15 | 16 | export interface VtbMoeVtbResponse { 17 | uuid: string 18 | type: string 19 | bot: boolean 20 | accounts: { 21 | id: string 22 | type: string 23 | platform: string 24 | }[] 25 | name: { 26 | extra: any[] 27 | cn: string 28 | default: string 29 | } 30 | } 31 | 32 | export interface VtbMoeDetailResponse { 33 | mid: number 34 | uuid: string 35 | uname: string 36 | video: number 37 | roomid: number 38 | sign: string 39 | notice: string 40 | face: string 41 | rise: number 42 | topPhoto: string 43 | archiveView: number 44 | follower: number 45 | liveStatus: number 46 | recordNum: number 47 | guardNum: number 48 | lastLive: any 49 | guardChange: number 50 | guardType: number[] 51 | online: number 52 | title: string 53 | time: number 54 | liveStartTime: number 55 | } -------------------------------------------------------------------------------- /src/types/cloudflare/index.ts: -------------------------------------------------------------------------------- 1 | export * from './workers-ai' 2 | 3 | export type Result<T> = { 4 | success: boolean 5 | result: T 6 | errors: { code: number, message: string}[] 7 | messages: string[] 8 | } -------------------------------------------------------------------------------- /src/types/cloudflare/workers-ai.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export type AIResponse = { 4 | response: string 5 | } -------------------------------------------------------------------------------- /src/types/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './leaf' 2 | export * from './react' 3 | export * from './schema' 4 | -------------------------------------------------------------------------------- /src/types/common/react.ts: -------------------------------------------------------------------------------- 1 | export type MaybeRef<T> = T | React.RefObject<T> 2 | 3 | export type UseState<T> = [T, React.Dispatch<React.SetStateAction<T>>] -------------------------------------------------------------------------------- /src/types/common/schema.ts: -------------------------------------------------------------------------------- 1 | export type HexColor = `#${string}` 2 | 3 | export type Optional<T> = T | null 4 | 5 | export type Enumerate<N extends number, Acc extends number[] = []> = Acc['length'] extends N 6 | ? Acc[number] 7 | : Enumerate<N, [...Acc, Acc['length']]> 8 | 9 | export type NumRange<F extends number, T extends number> = Exclude<Enumerate<T>, Enumerate<F>> | T 10 | 11 | export type HundredNumber = NumRange<0, 100> 12 | 13 | export type ArrElement<ArrType> = ArrType extends readonly (infer ElementType)[] 14 | ? ElementType 15 | : never 16 | 17 | export type Primitive = string | number | boolean | bigint | symbol | null | undefined 18 | 19 | export type KeyType = string | number | symbol 20 | 21 | export type ConvertToPrimitive<T extends Primitive> = T extends 'string' ? string : 22 | T extends 'number' ? number : 23 | T extends 'boolean' ? boolean : 24 | T extends 'bigint' ? bigint : 25 | T extends 'symbol' ? symbol : 26 | T extends 'null' ? null : 27 | T extends 'undefined' ? undefined : 28 | never 29 | 30 | export type RoomList = { 31 | list: { room: string, date: string }[], 32 | asBlackList: boolean 33 | } 34 | -------------------------------------------------------------------------------- /src/types/extends/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | 3 | interface RequestPipOptions { 4 | width: number; 5 | height: number; 6 | } 7 | 8 | interface DocumentPictureInPicture { 9 | // Instance properties 10 | window: Window; // Returns a Window instance representing the browsing context inside the Picture-in-Picture window. 11 | 12 | // Instance methods 13 | requestWindow(options?: RequestPipOptions): Promise<Window>; // Opens the Picture-in-Picture window for the current main browsing context. 14 | 15 | // Events 16 | onenter: Event; // Fired when the Picture-in-Picture window is successfully opened. 17 | } 18 | 19 | interface Window { 20 | documentPictureInPicture?: DocumentPictureInPicture 21 | flvjs?: any 22 | Hls?: any 23 | } 24 | 25 | interface WebSocket { 26 | onInterceptMessage: (msg: MessageEvent, realOnMessage: (msg: MessageEvent) => void) => void 27 | _send: (data: any) => void 28 | } 29 | 30 | interface HTMLIFrameElement { 31 | mozallowfullscreen: boolean 32 | msallowfullscreen: boolean 33 | oallowfullscreen: boolean 34 | webkitallowfullscreen: boolean 35 | } 36 | 37 | interface FileSystemDirectoryHandle { 38 | [Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]>; 39 | entries(): AsyncIterableIterator<[string, FileSystemHandle]>; 40 | keys(): AsyncIterableIterator<string>; 41 | values(): AsyncIterableIterator<FileSystemHandle>; 42 | remove(options?: { recursive: boolean }): Promise<void>; 43 | } 44 | 45 | interface HTMLMediaElement{ 46 | captureStream(): MediaStream; 47 | } 48 | 49 | } 50 | 51 | export { } -------------------------------------------------------------------------------- /src/types/extends/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './react-tailwind' 2 | export * from './global' -------------------------------------------------------------------------------- /src/types/extends/react-tailwind.d.ts: -------------------------------------------------------------------------------- 1 | import type { dismiss } from '@material-tailwind/react/types/components/menu'; 2 | 3 | declare module '@material-tailwind/react/types/components/menu' { 4 | export interface dismiss { 5 | itemPress?: boolean; 6 | isRequired?: object; 7 | } 8 | export interface MenuProps { 9 | dismiss?: dismiss; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/github/index.ts: -------------------------------------------------------------------------------- 1 | export * from './release' -------------------------------------------------------------------------------- /src/types/github/release.ts: -------------------------------------------------------------------------------- 1 | export interface ReleaseInfo { 2 | url: string; 3 | assets_url: string; 4 | upload_url: string; 5 | html_url: string; 6 | id: number; 7 | author: { 8 | login: string; 9 | id: number; 10 | node_id: string; 11 | avatar_url: string; 12 | gravatar_id: string; 13 | url: string; 14 | html_url: string; 15 | followers_url: string; 16 | following_url: string; 17 | gists_url: string; 18 | starred_url: string; 19 | subscriptions_url: string; 20 | organizations_url: string; 21 | repos_url: string; 22 | events_url: string; 23 | received_events_url: string; 24 | type: string; 25 | site_admin: boolean; 26 | }; 27 | node_id: string; 28 | tag_name: string; 29 | target_commitish: string; 30 | name: string; 31 | draft: boolean; 32 | prerelease: boolean; 33 | created_at: string; 34 | published_at: string; 35 | assets: any[]; 36 | tarball_url: string; 37 | zipball_url: string; 38 | body: string; 39 | mentions_count: number; 40 | } 41 | -------------------------------------------------------------------------------- /src/types/media/index.ts: -------------------------------------------------------------------------------- 1 | export * from './player' 2 | export * from './recorder' -------------------------------------------------------------------------------- /src/types/media/player.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { EventType, VideoInfo, StreamParseEvent, EventHandler } from "~players" 3 | 4 | export abstract class StreamPlayer { 5 | 6 | protected readonly eventHandlers: Map<EventType, Set<EventHandler<EventType>>> = new Map() 7 | 8 | abstract get internalPlayer(): any 9 | 10 | abstract get isSupported(): boolean 11 | 12 | abstract get videoInfo(): VideoInfo 13 | 14 | abstract play(url: string, media?: HTMLMediaElement): Promise<void> 15 | 16 | abstract stopAndDestroy(): void 17 | 18 | on<E extends EventType>(event: E, handler: EventHandler<E>): void { 19 | const handlers = this.eventHandlers.get(event) 20 | if (!handlers) { 21 | this.eventHandlers.set(event, new Set([handler])) 22 | return 23 | } 24 | handlers.add(handler) 25 | } 26 | 27 | off<E extends EventType>(event: E, handler: EventHandler<E>): void { 28 | const handlers = this.eventHandlers.get(event) 29 | if (handlers) handlers.delete(handler) 30 | } 31 | 32 | protected emit<E extends EventType>(event: E, payload: StreamParseEvent[E]): void { 33 | const handlers = this.eventHandlers.get(event) 34 | for (const handler of (handlers || [])) { 35 | handler(payload) 36 | } 37 | } 38 | 39 | protected clearHandlers(): void { 40 | this.eventHandlers.clear() 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/utils/database.ts: -------------------------------------------------------------------------------- 1 | import type { Table } from "dexie" 2 | import db, { type CommonSchema } from '~database' 3 | 4 | /** 5 | * Retrieves all tables from the database. 6 | * @returns An array of tables. 7 | */ 8 | export const getAllTables = () => Object.entries(db).filter(([, value]) => isTable(value)).map(([, v]) => v) as Table<CommonSchema, number>[] 9 | 10 | //create a type guard for the tables 11 | function isTable<T extends Table<CommonSchema, number>>(table: Table<CommonSchema, number>): table is T { 12 | return table?.where !== undefined 13 | } -------------------------------------------------------------------------------- /src/utils/event.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | export type FuncEventResult = { 4 | success: boolean 5 | error?: string 6 | } 7 | 8 | 9 | export function isFuncEventResult(obj: any): obj is FuncEventResult { 10 | return typeof obj === 'object' && typeof obj?.success === 'boolean' 11 | } 12 | 13 | /** 14 | * Dispatches a function event and returns a promise that resolves with the result. 15 | * @param func The name of the function to be executed. 16 | * @param args The arguments to be passed to the function. 17 | * @returns A promise that resolves with the result of the function execution. 18 | */ 19 | export function dispatchFuncEvent(func: string, ...args: any[]): Promise<FuncEventResult> { 20 | const id = window.crypto.randomUUID() 21 | return new Promise<FuncEventResult>((res) => { 22 | const callbackListener = (e: CustomEvent) => { 23 | window.removeEventListener(`bjf:func:callback:${id}`, callbackListener) 24 | res(e.detail) 25 | } 26 | window.addEventListener(`bjf:func:callback:${id}`, callbackListener) 27 | // 60s timeout 28 | setTimeout(() => { 29 | window.removeEventListener(`bjf:func:callback:${id}`, callbackListener) 30 | res({ success: false, error: `執行函數 ${func} 逾時` }) 31 | }, 60000) 32 | window.dispatchEvent(new CustomEvent(`bjf:func:${func}`, { detail: { args, id } })) 33 | }) 34 | } 35 | 36 | 37 | /** 38 | * Injects a function as a listener. 39 | * 40 | * @param func - The function to be injected as a listener. 41 | */ 42 | export function injectFuncAsListener(func: (...args: any[]) => void | Promise<void>) { 43 | addFuncEventListener(func.name, func) 44 | } 45 | 46 | /** 47 | * Adds a function event listener. 48 | * 49 | * @param func - The name of the function. 50 | * @param callback - The callback function to be executed when the event is triggered. 51 | * @param once - Optional. Specifies whether the listener should be removed after being triggered once. Default is false. 52 | */ 53 | export function addFuncEventListener(func: string, callback: (...args: any[]) => void | Promise<void>, once: boolean = false) { 54 | const listener = async (e: CustomEvent) => { 55 | try { 56 | const res = callback(...e.detail.args) 57 | if (res instanceof Promise) { 58 | await res 59 | } 60 | window.dispatchEvent(new CustomEvent(`bjf:func:callback:${e.detail.id}`, { detail: { success: true } })) 61 | } catch (err: Error | any) { 62 | console.error(`函數${func}執行時出現錯誤`, err) 63 | window.dispatchEvent(new CustomEvent(`bjf:func:callback:${e.detail.id}`, { detail: { success: false, error: err.toString() } })) 64 | } finally { 65 | if (once) window.removeEventListener(`bjf:func:${func}`, listener) 66 | } 67 | } 68 | window.addEventListener(`bjf:func:${func}`, listener) 69 | } -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Downloads a file with the specified filename, content, and type. 3 | * @param filename - The name of the file to be downloaded. 4 | * @param content - The content of the file. 5 | * @param type - The MIME type of the file. Defaults to 'text/plain'. 6 | */ 7 | export function download(filename: string, content: any | any[], type: string = 'text/plain') { 8 | const file = new Blob(Array.isArray(content) ? content : [content], { type }) 9 | downloadBlob(file, filename) 10 | } 11 | 12 | 13 | /** 14 | * Downloads a Blob object as a file. 15 | * 16 | * @param blob - The Blob object to download. 17 | * @param filename - The name of the file to be downloaded. 18 | * @returns A Promise that resolves when the download is complete. 19 | */ 20 | export function downloadBlob(blob: Blob, filename: string) { 21 | const a = document.createElement('a') 22 | a.href = URL.createObjectURL(blob) 23 | a.download = filename 24 | a.click() 25 | URL.revokeObjectURL(a.href) 26 | } 27 | 28 | /** 29 | * Reads a file as JSON and returns a promise that resolves with the parsed JSON object. 30 | * @param file - The file to read. 31 | * @returns A promise that resolves with the parsed JSON object. 32 | */ 33 | export function readAsJson<T extends object>(file: File): Promise<T> { 34 | return new Promise<T>((resolve, reject) => { 35 | const reader = new FileReader() 36 | reader.onload = (e) => { 37 | try { 38 | resolve(JSON.parse(e.target.result as string) as T) 39 | } catch (err: Error | any) { 40 | reject(err) 41 | } 42 | } 43 | reader.onerror = (e) => reject(e) 44 | reader.readAsText(file, 'utf-8') 45 | }) 46 | } 47 | 48 | /** 49 | * Checks if the code is running in the background script. 50 | * @returns A boolean value indicating whether the code is running in the background script. 51 | */ 52 | export function isBackgroundScript(): boolean { 53 | return chrome.tabs !== undefined 54 | } 55 | 56 | /** 57 | * Extracts the resource name from a URL. 58 | * @param url - The URL from which to extract the resource name. 59 | * @returns The extracted resource name. 60 | */ 61 | export function getResourceName(url: string): string { 62 | return url.split('/').pop().split('?')[0] 63 | } -------------------------------------------------------------------------------- /src/utils/func.ts: -------------------------------------------------------------------------------- 1 | import type { InjectableFunction, InjectableFunctionParameters, InjectableFunctionType } from "~background/functions" 2 | 3 | 4 | /** 5 | * Creates a higher-order function that flattens the execution of the provided function. 6 | * 7 | * @template T - The type of the provided function. 8 | * @template R - The type of the parameters of the provided function. 9 | * @param fn - The function to be flattened. 10 | * @param args - The arguments to be passed to the function. 11 | * @returns A function that, when called, executes the provided function with the given arguments. 12 | * 13 | * @example 14 | * // Define a function 15 | * function add(a: number, b: number): number { 16 | * return a + b 17 | * } 18 | * 19 | * // Create a flattened function 20 | * const flattenedAdd = flat(add, 2, 3) 21 | * 22 | * // Call the flattened function 23 | * const result = flattenedAdd() // Returns 5 24 | */ 25 | export function flat<T extends (...args: any[]) => any, R extends Parameters<T>>(fn: T, ...args: R): () => ReturnType<T> { 26 | return () => fn(...args) 27 | } 28 | 29 | 30 | /** 31 | * Wraps a function and returns a new function that, when called, invokes the original function 32 | * with the provided arguments and returns a function that, when called, invokes the original function 33 | * with the original arguments and returns the original function's return value. 34 | * 35 | * @template T - The type of the original function. 36 | * @template R - The type of the arguments of the original function. 37 | * @param {T} fn - The original function to wrap. 38 | * @returns {(...args: R) => (() => ReturnType<T>)} - The wrapped function. 39 | * 40 | * @example 41 | * // Define a function 42 | * function add(a: number, b: number): number { 43 | * return a + b 44 | * } 45 | * 46 | * // Wrap the function 47 | * const wrappedAdd = wrap(add) 48 | * 49 | * // Invoke the wrapped function 50 | * const result = wrappedAdd(2, 3) 51 | * console.log(result()) // Output: 5 52 | */ 53 | export function wrap<T extends (...args: any[]) => any, R extends Parameters<T>>(fn: T): (...args: R) => (() => ReturnType<T>) { 54 | return (...args: R) => flat(fn, ...args) 55 | } 56 | 57 | 58 | /** 59 | * Creates an injectable function that can be used for background sendMessagers. 60 | * @param key - The key representing the injectable function. 61 | * @returns A function that takes arguments and returns an injectable function. 62 | * @example 63 | * // Define an injectable function 64 | * const myFunction = inject('myFunction') 65 | * 66 | * // Use the injectable function 67 | * const result = myFunction('arg1', 'arg2') 68 | * 69 | * // Then run it for background sendMessagers 70 | * const result = await sendMessager('inject-func', { function: result }) 71 | */ 72 | export function inject<K extends InjectableFunctionType>(key: K): (...args: InjectableFunctionParameters<K>) => InjectableFunction<K> { 73 | return (...args) => ({ name: key as K, args }) 74 | } 75 | 76 | 77 | export default { 78 | flat, 79 | wrap, 80 | inject 81 | } -------------------------------------------------------------------------------- /src/utils/react-node.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | /** 4 | * Checks if the given node is an iterable node. 5 | * @param node - The node to check. 6 | * @returns True if the node is an array of React nodes, false otherwise. 7 | */ 8 | export function isIterableNode(node: React.ReactNode): node is Array<React.ReactNode> { 9 | return node instanceof Array 10 | } 11 | 12 | /** 13 | * Finds a static component within the given React node tree. 14 | * 15 | * @param children - The React node tree to search within. 16 | * @param name - The name of the component to find. 17 | * @returns The found React node if it matches the given name, otherwise null. 18 | */ 19 | export function findStaticComponent(children: React.ReactNode, name: string): React.ReactNode { 20 | if (isIterableNode(children)) { 21 | return children.find((child: any) => child.type.name === name) 22 | } 23 | return null 24 | } 25 | 26 | 27 | /** 28 | * Injects a script element with the specified code into the given element. 29 | * If no element is provided, the script element will be appended to the document body. 30 | * 31 | * @param code The code to be injected as the content of the script element. 32 | * @param element The element to which the script element should be appended. Defaults to document.body. 33 | */ 34 | export function injectScriptElement(code: string, element: Element = document.body) { 35 | const script = document.createElement('script') 36 | script.textContent = code 37 | element.appendChild(script) 38 | } 39 | 40 | 41 | /** 42 | * Creates or finds an HTML element with the specified tag name and id. 43 | * If the element does not exist, it is created and appended to the specified parent element. 44 | * If the element already exists, it is returned. 45 | * 46 | * @param tagName - The tag name of the HTML element. 47 | * @param id - The id of the HTML element. 48 | * @param parent - The parent element to which the created element will be appended. Defaults to document.body. 49 | * @returns The created or found HTML element. 50 | */ 51 | export function findOrCreateElement(tagName: string, id: string = "", parent: Element = document.documentElement): Element { 52 | const selector = `${tagName}${id ? `#${id}` : ''}` 53 | let element = parent.querySelector(selector) 54 | if (!element) { 55 | element = document.createElement(tagName) 56 | if (id) element.id = id 57 | if (parent !== document.documentElement) parent.appendChild(element) 58 | } 59 | return element 60 | } -------------------------------------------------------------------------------- /src/utils/subscriber.ts: -------------------------------------------------------------------------------- 1 | import { ID, addWindowMessageListener } from "./messaging" 2 | 3 | import type { BLiveDataWild } from "~types/bilibili" 4 | 5 | export type BLiveListener<K extends string> = (command: BLiveDataWild<K>, event: MessageEvent) => void 6 | 7 | const listenerMap = new Map<string, BLiveListener<any>[]>() 8 | 9 | let removeListener: VoidFunction = null 10 | 11 | if (removeListener !== null) { 12 | removeListener() 13 | removeListener = null 14 | } 15 | 16 | 17 | removeListener = addWindowMessageListener('blive-ws', (data: { cmd: string, command: any, eventId: string }, event) => { 18 | 19 | const listeners = listenerMap.get(data.cmd) ?? [] 20 | 21 | listeners.forEach(listener => listener(data.command, event)) 22 | 23 | delete data.command.dm_v2 // delete dm_v2 to apply modification 24 | event.source.postMessage({ source: ID, data: { command: `ws:callback:${data.eventId}`, body: data } }, { targetOrigin: event.origin }) 25 | 26 | }) 27 | 28 | 29 | /** 30 | * Adds a BLive subscriber for a specific command. 31 | * 32 | * @template K - The type of the command. 33 | * @param {K} command - The command to subscribe to. 34 | * @param {BLiveListener<K>} callback - The callback function to be executed when the command is triggered. 35 | * @returns {VoidFunction} - A function that can be called to unsubscribe the callback from the command. 36 | */ 37 | export function addBLiveSubscriber<K extends string>(command: K, callback: BLiveListener<K>): VoidFunction { 38 | listenerMap.set(command, [...(listenerMap.get(command) ?? []), callback]) 39 | return () => { 40 | listenerMap.set(command, listenerMap.get(command).filter(v => v !== callback)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import withMT from "@material-tailwind/react/utils/withMT"; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default withMT({ 5 | content: ["./src/**/*.{tsx,html}"], 6 | theme: { 7 | extend: {} 8 | }, 9 | darkMode: "class" 10 | }) 11 | -------------------------------------------------------------------------------- /tests/fixtures/background.ts: -------------------------------------------------------------------------------- 1 | import type { Page, Worker } from "@playwright/test"; 2 | import BilibiliPage from "@tests/helpers/bilibili-page"; 3 | import { Strategy } from "@tests/utils/misc"; 4 | import { extensionBase } from "./extension"; 5 | 6 | 7 | export type BackgroundOptions = { 8 | } 9 | 10 | export type BackgroundFixtures = { 11 | front: BilibiliPage 12 | serviceWorker: Worker 13 | } 14 | 15 | 16 | export const test = extensionBase.extend<BackgroundFixtures>({ 17 | // 直播间页面用 18 | front: async ({ context, rooms, api, isThemeRoom, cacher }, use) => { 19 | const frontPage = await context.newPage() 20 | await using room = new BilibiliPage(frontPage, api) 21 | const generator = Strategy.random(rooms, Math.min(rooms.length, 5)) 22 | const info = await cacher.findRoomTypeWithCache(isThemeRoom ? 'theme' : 'normal', generator) 23 | test.skip(!info, `找不到${isThemeRoom ? '' : '不是'}大海報的房間。`) 24 | await room.enterToRoom(info) 25 | test.skip(await room.checkIfNotSupport(), '瀏覽器版本過低') 26 | await use(room) 27 | } 28 | }) 29 | 30 | 31 | export const expect = test.expect -------------------------------------------------------------------------------- /tests/fixtures/base.ts: -------------------------------------------------------------------------------- 1 | import { expect, test as pwBase } from '@playwright/test' 2 | import BilbiliApi, { type LiveRoomInfo } from '@tests/helpers/bilibili-api' 3 | 4 | export type BaseOptions = { 5 | maxPage: number 6 | roomId: number 7 | } 8 | 9 | export type BaseWorkerFixtures = { 10 | api: BilbiliApi 11 | rooms: LiveRoomInfo[] 12 | } 13 | 14 | export const base = pwBase.extend<{}, BaseOptions & BaseWorkerFixtures>({ 15 | 16 | maxPage: [5, { option: true, scope: 'worker' }], 17 | roomId: [-1, { option: true, scope: 'worker' }], 18 | 19 | rooms: [ 20 | async ({ maxPage, roomId, api }, use) => { 21 | const rooms = roomId > 0 ? [await api.findLiveRoom(roomId)] : await api.getLiveRoomsRange(maxPage) 22 | expect(rooms, 'rooms is not undefined').toBeDefined() 23 | expect(rooms.length, 'live rooms more than 1').toBeGreaterThan(0) 24 | pwBase.skip(rooms[0] === null, 'failed to fetch bilibili live room') 25 | await use(rooms) 26 | }, 27 | { scope: 'worker' } 28 | ], 29 | api: [ 30 | async ({ }, use) => { 31 | const api = await BilbiliApi.init() 32 | await use(api) 33 | }, 34 | { scope: 'worker' } 35 | ], 36 | 37 | }) -------------------------------------------------------------------------------- /tests/helpers/listeners/dismiss-login-dialog-listener.ts: -------------------------------------------------------------------------------- 1 | import type { PageFrame } from "../page-frame"; 2 | import { PageListener } from "./type"; 3 | 4 | // 监听器:关闭登录对话框 5 | class DismissLoginDialogListener extends PageListener { 6 | 7 | constructor() { 8 | super('dismiss login dialog listener') 9 | } 10 | 11 | protected async run(content: PageFrame): Promise<void> { 12 | const loginDialogDismissButton = content.locator('body > div.bili-mini-mask > div > div.bili-mini-close-icon') 13 | if (await loginDialogDismissButton.isVisible({ timeout: 500 })) { 14 | await loginDialogDismissButton.click({ timeout: 500, force: true }) 15 | this.logger.debug('dismissed login dialog') 16 | } 17 | } 18 | 19 | } 20 | 21 | export default DismissLoginDialogListener -------------------------------------------------------------------------------- /tests/helpers/listeners/type.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@tests/utils/misc"; 2 | import { createLogger, type Logger } from "../logger"; 3 | import { isClosed, type PageFrame } from "../page-frame"; 4 | 5 | /** 6 | * 页面监听器的抽象基类。 7 | */ 8 | export abstract class PageListener { 9 | 10 | protected readonly logger: Logger 11 | protected instance: NodeJS.Timeout | null = null 12 | 13 | /** 14 | * 创建一个页面监听器实例。 15 | * @param name 监听器的名称。 16 | * @param interval 监听器执行的时间间隔,默认为 1000 毫秒。 17 | */ 18 | constructor(name: string, private readonly interval: number = 1000) { 19 | this.logger = createLogger(name, env('CI', Boolean)) 20 | } 21 | 22 | /** 23 | * 启动监听器并开始监听页面内容。 24 | * @param content 页面的内容。 25 | */ 26 | start(content: PageFrame): void { 27 | 28 | if (this.instance) { 29 | this.logger.info('清除上一个时间间隔') 30 | clearInterval(this.instance) 31 | } 32 | 33 | if (content === null) { 34 | this.logger.warn('内容为空,无法启动监听器') 35 | return 36 | } 37 | 38 | this.instance = setInterval(() => { 39 | 40 | if (isClosed(content)) { 41 | this.logger.info('帧/页面已关闭,监听器中止') 42 | clearInterval(this.instance) 43 | return 44 | } 45 | 46 | this.run(content) 47 | .catch(err => this.logger.warn('监听器错误: ', err)) 48 | 49 | }, this.interval) 50 | } 51 | 52 | /** 53 | * 在子类中实现的方法,用于执行监听器的逻辑。 54 | * @param content 页面的内容。 55 | */ 56 | protected abstract run(content: PageFrame): Promise<void> 57 | 58 | /** 59 | * 停止监听器。 60 | */ 61 | stop(): void { 62 | if (this.instance) { 63 | clearInterval(this.instance) 64 | this.instance = null 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /tests/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Logger { 3 | info: (...args: any[]) => void 4 | log: (...args: any[]) => void 5 | warn: (...args: any[]) => void 6 | error: (...args: any[]) => void 7 | debug: (...args: any[]) => void 8 | trace: (...args: any[]) => void 9 | } 10 | 11 | class LoggerImpl implements Logger { 12 | 13 | private static void = () => { } 14 | 15 | readonly info: (...args: any[]) => void 16 | readonly log: (...args: any[]) => void 17 | readonly warn: (...args: any[]) => void 18 | readonly error: (...args: any[]) => void 19 | readonly debug: (...args: any[]) => void 20 | readonly trace: (...args: any[]) => void 21 | 22 | constructor(name: string, ci: boolean = false) { 23 | this.info = !ci ? console.info.bind(console, `[${name}]`) : LoggerImpl.void 24 | this.log = !ci ? console.log.bind(console, `[${name}]`) : LoggerImpl.void 25 | this.warn = !ci ? console.warn.bind(console, `[${name}]`) : LoggerImpl.void 26 | this.error = !ci ? console.error.bind(console, `[${name}]`) : LoggerImpl.void 27 | this.debug = !ci ? console.debug.bind(console, `[${name}]`) : LoggerImpl.void 28 | this.trace = !ci ? console.trace.bind(console, `[${name}]`) : LoggerImpl.void 29 | } 30 | 31 | } 32 | 33 | export function createLogger(name: string, ci: boolean = false): Logger { 34 | return new LoggerImpl(name, ci) 35 | } 36 | 37 | const logger = createLogger('bilibili-vup-stream-enhancer', !!process.env.CI && !process.env.DEBUG) 38 | 39 | export default logger -------------------------------------------------------------------------------- /tests/helpers/page-frame.ts: -------------------------------------------------------------------------------- 1 | import type { Frame, Page } from "@playwright/test"; 2 | 3 | export type PageFrame = Page | Frame 4 | 5 | /** 6 | * Checks if a page frame is closed or detached. 7 | * @param page - The page frame to check. 8 | * @returns A boolean indicating whether the page is closed or detached. 9 | */ 10 | export function isClosed(page: PageFrame): boolean { 11 | return ('isClosed' in page && page.isClosed()) || ('isDetached' in page && page.isDetached()) 12 | } 13 | 14 | /** 15 | * Checks if the given object is an instance of `PageFrame`. 16 | * @param page - The object to be checked. 17 | * @returns `true` if the object is an instance of `Page`, `false` otherwise. 18 | */ 19 | export function isPage(page: PageFrame): page is Page { 20 | return 'isClosed' in page 21 | } 22 | 23 | /** 24 | * Checks if the given page is a frame. 25 | * @param page - The page to check. 26 | * @returns True if the page is a frame, false otherwise. 27 | */ 28 | export function isFrame(page: PageFrame): page is Frame { 29 | return 'isDetached' in page 30 | } -------------------------------------------------------------------------------- /tests/integrations/summarizer.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@tests/fixtures/component"; 2 | import logger from "@tests/helpers/logger"; 3 | import createLLMProvider, { type LLMTypes } from "~llms"; 4 | 5 | const prompt = `这位是一名在b站直播间直播的日本vtuber说过的话,请根据下文对话猜测与观众的互动内容,并用中文总结一下他们的对话:\n\n${[ 6 | '大家好', 7 | '早上好', 8 | '知道我今天吃了什么吗?', 9 | '是麦当劳哦!', 10 | '"不就个麦当劳而已吗"不是啦', 11 | '是最近那个很热门的新品', 12 | '对,就是那个', 13 | '然后呢, 今天久违的出门了', 14 | '对,平时都是宅在家里的呢', 15 | '"终于长大了"喂w', 16 | '然后今天去了漫展来着', 17 | '很多人呢', 18 | '之前的我看到那么多人肯定社恐了', 19 | '但今次意外的没有呢', 20 | '"果然是长大了"也是呢', 21 | '然后呢, 今天买了很多东西', 22 | '插画啊,手办啊,周边之类的', 23 | '荷包大出血w', 24 | '不过觉得花上去应该值得的...吧?', 25 | '喂,好过分啊', 26 | '不过确实不应该花那么多钱的', 27 | '然后呢,回家途中看到了蟑螂的尸体', 28 | '太恶心了', 29 | '然后把我一整天好心情搞没了w', 30 | '"就因为一个蟑螂"对www', 31 | '不过跟你们谈完反而心情好多了', 32 | '谢谢大家', 33 | '那么今天的杂谈就到这里吧', 34 | '下次再见啦', 35 | '拜拜~' 36 | ].join('\n')}` as const 37 | 38 | function testModel(model: string, { trash = false, provider = 'worker' }: { trash?: boolean, provider?: LLMTypes } = {}) { 39 | return async function () { 40 | 41 | logger.info(`正在测试模型 ${model} ...`) 42 | 43 | const llm = createLLMProvider({ 44 | provider, 45 | model 46 | }) 47 | 48 | const res = await llm.prompt(prompt) 49 | logger.info(`模型 ${model} 的总结结果`, res) 50 | 51 | const maybe = expect.configure({ soft: true }) 52 | maybe(res).toMatch(/主播|日本VTuber|日本vtuber|vtuber/) 53 | maybe(res).toMatch(/直播|观众/) 54 | 55 | if (!trash) { 56 | maybe(res).toContain('麦当劳') 57 | maybe(res).toContain('漫展') 58 | maybe(res).toContain('蟑螂') 59 | } 60 | } 61 | } 62 | 63 | test.slow() 64 | 65 | test('测试 @cf/qwen/qwen1.5-14b-chat-awq 模型的AI总结结果', testModel('@cf/qwen/qwen1.5-14b-chat-awq')) 66 | 67 | test('测试 @cf/qwen/qwen1.5-7b-chat-awq 模型的AI总结结果', testModel('@cf/qwen/qwen1.5-7b-chat-awq')) 68 | 69 | test('测试 @cf/qwen/qwen1.5-1.8b-chat 模型的AI总结结果', testModel('@cf/qwen/qwen1.5-1.8b-chat')) 70 | 71 | // this model is too trash that cannot have any keywords 72 | test('测试 @hf/google/gemma-7b-it 模型的AI总结结果', testModel('@hf/google/gemma-7b-it', { trash: true })) 73 | 74 | test('测试 @hf/nousresearch/hermes-2-pro-mistral-7b 模型的AI总结结果', testModel('@hf/nousresearch/hermes-2-pro-mistral-7b')) -------------------------------------------------------------------------------- /tests/modules/ffmpeg.js: -------------------------------------------------------------------------------- 1 | import { FFmpeg } from "@ffmpeg/ffmpeg" 2 | import { toBlobURL } from "@ffmpeg/util" 3 | 4 | const ffmpeg = new FFmpeg() 5 | const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm" 6 | 7 | window.getFFmpeg = async function(){ 8 | if (!ffmpeg.loaded) { 9 | console.info('loading ffmpeg for first time...') 10 | await ffmpeg.load({ 11 | coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "application/javascript"), 12 | wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), 13 | classWorkerURL: await toBlobURL('https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/814.ffmpeg.js', "application/javascript") 14 | }) 15 | console.info('ffmpeg loaded!') 16 | ffmpeg.on("log", ({ type, message }) => console.log(`[${type}] ${message}`)) 17 | ffmpeg.on("progress", ({ progress, time }) => { 18 | console.log(`progressing: ${progress * 100} % (transcoded time: ${time / 1000000} s)`) 19 | }) 20 | } 21 | return ffmpeg 22 | } -------------------------------------------------------------------------------- /tests/modules/llm.js: -------------------------------------------------------------------------------- 1 | import createLLMProvider from '~llms' 2 | 3 | window.llms = { createLLMProvider } -------------------------------------------------------------------------------- /tests/modules/player.js: -------------------------------------------------------------------------------- 1 | import loadStream, { recordStream } from '~players' 2 | 3 | window.player = { loadStream, recordStream } -------------------------------------------------------------------------------- /tests/modules/recorder.js: -------------------------------------------------------------------------------- 1 | import createRecorder from '~features/recorder/recorders' 2 | 3 | window.createRecorder = createRecorder -------------------------------------------------------------------------------- /tests/modules/utils.js: -------------------------------------------------------------------------------- 1 | import * as file from '~utils/file' 2 | import * as misc from '~utils/misc' 3 | import * as ffmpeg from '@ffmpeg/util' 4 | 5 | 6 | console.log('utils.js loaded') 7 | 8 | window.utils = { file, ffmpeg, misc } -------------------------------------------------------------------------------- /tests/options.ts: -------------------------------------------------------------------------------- 1 | import type { BackgroundOptions } from "./fixtures/background" 2 | import type { ExtensionOptions } from "./fixtures/extension" 3 | import type { ContentOptions } from "./fixtures/content" 4 | import type { BaseOptions } from "./fixtures/base" 5 | 6 | export type GlobalOptions = 7 | ContentOptions & 8 | BackgroundOptions & 9 | ExtensionOptions & 10 | BaseOptions -------------------------------------------------------------------------------- /tests/pages/encoder.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@tests/fixtures/background"; 2 | 3 | 4 | 5 | test('測試加載多線程 ffmpeg.wasm ', async ({ page, tabUrl }) => { 6 | await page.goto(tabUrl('encoder.html?id=12345'), { waitUntil: 'domcontentloaded' }) 7 | await expect(page.getByText('正在加载 FFMpeg')).toBeVisible() 8 | await expect(page.getByText('FFMpeg 已成功加载。')).toBeVisible() 9 | }) 10 | 11 | 12 | test('測試不帶ID時顯示無效的請求', async ({ page, tabUrl }) => { 13 | await page.goto(tabUrl('encoder.html'), { waitUntil: 'domcontentloaded' }) 14 | await expect(page.getByText('无效的请求')).toBeVisible() 15 | }) -------------------------------------------------------------------------------- /tests/theme.setup.ts: -------------------------------------------------------------------------------- 1 | import { extensionBase as setup } from "./fixtures/extension"; 2 | import logger from "./helpers/logger"; 3 | import { Strategy } from "./utils/misc"; 4 | 5 | setup('預先搜索大海報房間', async ({ cacher, rooms, maxRoomRetries, roomId }) => { 6 | setup.setTimeout(0) 7 | setup.skip(roomId > 0, '已指定直播房間,跳過搜索') 8 | setup.skip(rooms.length < 2, '房間數量不足,跳過搜索') 9 | console.info('正在搜索大海報房間...') 10 | if (!process.env.CI) { 11 | const info = cacher.findRoomTypeFromCache('theme') 12 | setup.skip(!!info, '已從緩存中找到大海報房間: ' + info?.roomid) 13 | } 14 | logger.info('rooms: ', rooms.map(r => r.roomid)) 15 | const generator = Strategy.random(rooms, Math.min(maxRoomRetries, rooms.length)) 16 | const info = await cacher.findRoomType('theme', generator) 17 | if (!info) { 18 | console.warn(`找不到大海報的房間`) 19 | } else { 20 | console.info(`成功找到大海報房間: ${info.roomid}`) 21 | } 22 | await cacher.writeToFileCache('theme', info) 23 | console.info(`已將大海報房間 ${info?.roomid ?? '無'} 寫入緩存`) 24 | }) -------------------------------------------------------------------------------- /tests/types/movie.ts: -------------------------------------------------------------------------------- 1 | export interface Movie { 2 | duration: number; 3 | timescale: number; 4 | tracks: (VideoTrack | AudioTrack)[]; 5 | 6 | relativeDuration(): number 7 | resolution(): string 8 | size(): number 9 | addTrack(track: VideoTrack | AudioTrack): void 10 | videoTrack(): VideoTrack 11 | audioTrack(): AudioTrack 12 | samples(): number[] 13 | ensureDuration(): number 14 | 15 | } 16 | 17 | export interface VideoTrack { 18 | duration: number; 19 | timescale: number; 20 | extraData: Buffer; 21 | codec: string; 22 | samples: Sample[]; 23 | width: number; 24 | height: number; 25 | } 26 | 27 | export interface AudioTrack { 28 | duration: number; 29 | timescale: number; 30 | extraData: Buffer; 31 | codec: string; 32 | samples: Sample[]; 33 | channels: number; 34 | sampleRate: number; 35 | sampleSize: number; 36 | } 37 | 38 | export interface Sample { 39 | timestamp: number 40 | timescale: number 41 | size: number 42 | offset: number 43 | 44 | relativeTimestamp(): number 45 | } -------------------------------------------------------------------------------- /tests/units/bilibili-api.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@tests/fixtures/component"; 2 | import logger from "@tests/helpers/logger"; 3 | 4 | test('getRoomStatus - API', async ({ api }) => { 5 | await expectNoError(api.getRoomStatus(545)) 6 | }) 7 | 8 | test('findLiveRoom - API', async ({ api }) => { 9 | await expectNoError(api.findLiveRoom(545)) 10 | }) 11 | 12 | test('getLiveRoomsRange - API', async ({ api }) => { 13 | await expectNoError(api.getLiveRoomsRange(3)) 14 | }) 15 | 16 | test('getLiveRooms - API', async ({ api }) => { 17 | await expectNoError(api.getLiveRooms()) 18 | }) 19 | 20 | test('getStreamUrls - API', async ({ api }) => { 21 | await expectNoError(api.getStreamUrls(21696950)) 22 | }) 23 | 24 | async function expectNoError(p: Promise<any>) { 25 | try { 26 | const r = await p 27 | if (!r) return 28 | logger.info('result: ', r) 29 | } catch (e) { 30 | logger.error(e) 31 | throw e 32 | } 33 | } -------------------------------------------------------------------------------- /tests/utils/bilibili.ts: -------------------------------------------------------------------------------- 1 | import logger from "@tests/helpers/logger"; 2 | import type { PageFrame } from "@tests/helpers/page-frame"; 3 | 4 | /** 5 | * Sends a fake BLive message to the specified page frame. 6 | * @param content The page frame to send the message to. 7 | * @param cmd The command to send. 8 | * @param command The command object to send. 9 | * @returns A promise that resolves when the message is sent. 10 | */ 11 | export function sendFakeBLiveMessage(content: PageFrame, cmd: string, command: object) { 12 | logger.debug('sending blive fake message into: ', cmd, content.url()) 13 | return content.evaluate(([cmd, command]) => { 14 | const eventId = window.crypto.randomUUID() 15 | console.info(`[bilibili-vup-stream-enhancer-test] send fake blive message: ${cmd}`, command) 16 | window.postMessage({ 17 | source: 'bilibili-vup-stream-enhancer', 18 | data: { 19 | command: 'blive-ws', 20 | body: { cmd, command, eventId } 21 | } 22 | }, '*') 23 | }, [cmd, command]) 24 | } 25 | 26 | /** 27 | * Receives a single blive message from a page frame. 28 | * @param content - The page frame to receive the message from. 29 | * @param cmd - The command to filter the message by (optional). 30 | * @returns A promise that resolves with the received message. 31 | */ 32 | export function receiveOneBLiveMessage(content: PageFrame, cmd: string = ''): Promise<any> { 33 | logger.debug('waiting for blive fake message: ', cmd, content.url()) 34 | return content.evaluate(([cmd]) => { 35 | return new Promise((res, rej) => { 36 | const handler = (e: MessageEvent) => { 37 | if (e.source !== window) return 38 | if (e.data.source === 'bilibili-vup-stream-enhancer' && e.data.data.command === 'blive-ws') { 39 | const content = e.data.data.body 40 | if (cmd && content.cmd !== cmd) return 41 | window.removeEventListener('message', handler) 42 | res(content) 43 | } 44 | } 45 | window.addEventListener('message', handler) 46 | setTimeout(() => rej(new Error('timeout')), 60000) 47 | }) 48 | }, [cmd]) 49 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": [ 4 | "node_modules" 5 | ], 6 | "include": [ 7 | ".plasmo/index.d.ts", 8 | "./**/*.d.ts", 9 | "./**/*.ts", 10 | "./**/*.tsx", 11 | "./src/adapters/*.js" 12 | ], 13 | "compilerOptions": { 14 | "types": [ 15 | "@webgpu/types" 16 | ], 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "~*": [ 20 | "./src/*" 21 | ], 22 | "@tests/*": [ 23 | "./tests/*" 24 | ] 25 | }, 26 | "baseUrl": "." 27 | }, 28 | } 29 | --------------------------------------------------------------------------------