From 40ad1fe11e621fcd7d9e24705f85c1fc0dfa0867 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Thu, 14 Mar 2024 14:06:47 +0800 Subject: [PATCH 01/14] added button for viewing release log when notify updated --- .gitignore | 1 + src/background/update-listener.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 2816f403..786c92a8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ npm-debug.log* # local env files .env* +.vscode/ out/ build/ diff --git a/src/background/update-listener.ts b/src/background/update-listener.ts index aa038bf1..c3588d4c 100644 --- a/src/background/update-listener.ts +++ b/src/background/update-listener.ts @@ -32,6 +32,12 @@ chrome.runtime.onInstalled.addListener(async (data: chrome.runtime.InstalledDeta await sendInternal('notify', { title: 'bilibili-vup-stream-enhancer 已更新', message: `已更新到版本 v${version}`, + buttons: [ + { + title: '查看更新日志', + clicked: () => sendInternal('open-tab', { url: `https://github.com/eric2788/bilibili-vup-stream-enhancer/releases/tag/${version}` }) + } + ] }) const lastVersion = (await localStorage.get('last_version')) ?? '0.12.4' From 6ede0f9dfe89d6d41f7ef53cca26bbffcdbe1738 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Wed, 20 Mar 2024 12:50:30 +0800 Subject: [PATCH 02/14] Update PR branch naming convention in CONTRIBUTING.md --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f3c62ad..e27320ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,9 @@ ### 提交规范 - 本项目在 commit message 上没有限制, 清晰明了即可 -- PR分支请确保是基于 `develop` 分支创建,且分支名称应该为 `[类型]/[issue号]-[概要]` 的格式;例如 `feature/123-new-feature` +- PR分支请确保是基于 `develop` 分支创建,且分支名称应如下: + - 如果你的分支是基于一个 issue, 请使用 `[类型]/[issue号]-[概要]` 的格式;例如 `feature/123-new-feature` + - 如果你的分支并非基于一个 issue, 请使用 `[类型]/[概要]` 的格式;例如 `feature/new-feature` - 请确保你的每一条 commits 都有意义,如有必要请使用 `git rebase` 合并 commits - 如果你的 PR 是为了修复某个 issue,请在 PR 描述中写明 `Fixed|Resolved #issue号`,以便自动连结 issue From f66d6efc0035dc24e15c08d0328a81852831306e Mon Sep 17 00:00:00 2001 From: eric2788 Date: Wed, 20 Mar 2024 22:19:51 +0800 Subject: [PATCH 03/14] changed all display in full screen default settings to false --- src/settings/features/superchat/index.tsx | 2 +- src/settings/fragments/display.tsx | 2 +- tests/content.spec.ts | 10 +++++----- tests/features/superchat.spec.ts | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/settings/features/superchat/index.tsx b/src/settings/features/superchat/index.tsx index 413eb72f..01b8ef91 100644 --- a/src/settings/features/superchat/index.tsx +++ b/src/settings/features/superchat/index.tsx @@ -20,7 +20,7 @@ export type FeatureSettingSchema = { export const defaultSettings: Readonly = { floatingButtonColor: '#db7d1f', buttonColor: '#db7d1f', - displayFullScreen: true + displayFullScreen: false } diff --git a/src/settings/fragments/display.tsx b/src/settings/fragments/display.tsx index 933115f3..08687b23 100644 --- a/src/settings/fragments/display.tsx +++ b/src/settings/fragments/display.tsx @@ -18,7 +18,7 @@ export const defaultSettings: Readonly = { blackListButton: true, settingsButton: true, themeToNormalButton: true, - supportWebFullScreen: true + supportWebFullScreen: false } export const title = '界面按钮显示' diff --git a/tests/content.spec.ts b/tests/content.spec.ts index 20dcd849..6751b392 100644 --- a/tests/content.spec.ts +++ b/tests/content.spec.ts @@ -220,24 +220,24 @@ test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ conte const button = content.getByText('功能菜单') await expect(button).toBeVisible() - logger.info('正在測試啟用時切換網頁全屏...') + logger.info('正在測試禁用時切換網頁全屏...') await content.locator('#live-player').dblclick() - await expect(button).toBeVisible() + await expect(button).toBeHidden() await content.locator('#live-player').dblclick() logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('界面按钮显示').click() - await settingsPage.getByText('支持在网页全屏下显示').click() // closed + await settingsPage.getByText('支持在网页全屏下显示').click() // enabled await settingsPage.getByText('保存设定').click() await settingsPage.close() await expect(button).toBeVisible() - logger.info('正在測試禁用時切換網頁全屏...') + logger.info('正在測試啟用時切換網頁全屏...') await content.locator('#live-player').dblclick() - await expect(button).toBeHidden() + await expect(button).toBeVisible() await content.locator('#live-player').dblclick() await expect(button).toBeVisible() diff --git a/tests/features/superchat.spec.ts b/tests/features/superchat.spec.ts index 69ecc6cb..771c185c 100644 --- a/tests/features/superchat.spec.ts +++ b/tests/features/superchat.spec.ts @@ -190,24 +190,24 @@ test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ conte const button = content.locator('button', { hasText: /^醒目留言$/ }) await expect(button).toBeVisible() - logger.info('正在測試啟用時切換網頁全屏...') + logger.info('正在測試禁用時切換網頁全屏...') await content.locator('#live-player').dblclick() - await expect(button).toBeVisible() + await expect(button).toBeHidden() await content.locator('#live-player').dblclick() logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() - await settingsPage.locator('#features\\.superchat').getByText('在全屏模式下显示').click() // closed + await settingsPage.locator('#features\\.superchat').getByText('在全屏模式下显示').click() // enabled await settingsPage.getByText('保存设定').click() await settingsPage.close() await expect(button).toBeVisible() - logger.info('正在測試禁用時切換網頁全屏...') + logger.info('正在測試啟用時切換網頁全屏...') await content.locator('#live-player').dblclick() - await expect(button).toBeHidden() + await expect(button).toBeVisible() await content.locator('#live-player').dblclick() await expect(button).toBeVisible() From 473ad2dbcc7230f49f457d66b1d5cec63e5c7677 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Wed, 20 Mar 2024 22:24:00 +0800 Subject: [PATCH 04/14] remove jimaku list context menu when fullscreen --- src/features/jimaku/components/JimakuArea.tsx | 1 + src/features/jimaku/components/JimakuList.tsx | 15 ++++++++++----- tests/features/jimaku.spec.ts | 4 +++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/features/jimaku/components/JimakuArea.tsx b/src/features/jimaku/components/JimakuArea.tsx index fbbe1639..eea9ece7 100644 --- a/src/features/jimaku/components/JimakuArea.tsx +++ b/src/features/jimaku/components/JimakuArea.tsx @@ -140,6 +140,7 @@ function JimakuArea({ jimaku }: JimakuAreaProps): JSX.Element { diff --git a/src/features/jimaku/components/JimakuList.tsx b/src/features/jimaku/components/JimakuList.tsx index d72dbade..33487288 100644 --- a/src/features/jimaku/components/JimakuList.tsx +++ b/src/features/jimaku/components/JimakuList.tsx @@ -19,6 +19,7 @@ import ShadowStyle from "~components/ShadowStyle"; export type JimakuListProps = { jimaku: Jimaku[] style?: React.CSSProperties + fullScreen: boolean } @@ -26,7 +27,7 @@ export type JimakuListProps = { function JimakuList(props: JimakuListProps): JSX.Element { const { jimakuZone: jimakuStyle, listingZone } = useContext(JimakuFeatureContext) - const { jimaku, style } = props + const { jimaku, style, fullScreen } = props const { ref, element } = useKeepBottom( @@ -102,10 +103,14 @@ function JimakuList(props: JimakuListProps): JSX.Element { show={displayContextMenu(item)} /> ))} - {styleText} - - 屏蔽选中同传发送者 - + {!fullScreen && ( + <> + {styleText} + + 屏蔽选中同传发送者 + + + )} ) diff --git a/tests/features/jimaku.spec.ts b/tests/features/jimaku.spec.ts index 5e3b0041..0bdb0759 100644 --- a/tests/features/jimaku.spec.ts +++ b/tests/features/jimaku.spec.ts @@ -277,7 +277,9 @@ test('测试添加同传用户名单/黑名单', async ({ content, context, tabU } }) -test('測試右鍵同傳字幕來屏蔽同傳發送者', async ({ content, room, page }) => { +test('測試右鍵同傳字幕來屏蔽同傳發送者', async ({ content, room, page, isThemeRoom }) => { + + test.skip(isThemeRoom, '此測試不適用於大海報房間') await content.locator('#subtitle-list').waitFor({ state: 'visible' }) From acda39d9c73022c8f1b2612f87296a66164778e3 Mon Sep 17 00:00:00 2001 From: Eric Lam Date: Fri, 22 Mar 2024 18:04:25 +0800 Subject: [PATCH 05/14] =?UTF-8?q?[PR=20feat]=20=E8=AE=BE=E5=AE=9A=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20-=20=E6=96=B0=E5=A2=9E=E7=89=88=E6=9C=AC=E8=B5=84?= =?UTF-8?q?=E8=AE=AF=E8=AE=BE=E5=AE=9A=E5=8C=BA=E5=9D=97=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added version info fragment in setting page * added auto check update logic and remove unused * updated dependencies * fixed notification buttons not work --- CONTRIBUTING.md | 1 - package.json | 17 +- pnpm-lock.yaml | 1218 ++++++----------------- src/api/github.ts | 10 + src/background/messages.ts | 2 - src/background/messages/check-update.ts | 69 -- src/background/messages/notify.ts | 14 +- src/background/update-listener.ts | 28 +- src/settings/fragments/version.tsx | 106 ++ src/settings/index.ts | 4 +- src/tabs/settings.tsx | 209 ++-- src/types/github/index.d.ts | 1 + src/types/github/release.d.ts | 40 + src/updaters/chrome.ts | 4 - src/updaters/index.ts | 16 - src/utils/fetch.ts | 24 + tests/pages/settings.spec.ts | 2 +- 17 files changed, 620 insertions(+), 1145 deletions(-) create mode 100644 src/api/github.ts delete mode 100644 src/background/messages/check-update.ts create mode 100644 src/settings/fragments/version.tsx create mode 100644 src/types/github/index.d.ts create mode 100644 src/types/github/release.d.ts delete mode 100644 src/updaters/chrome.ts delete mode 100644 src/updaters/index.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e27320ff..2d3a4915 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,6 @@ src/ ├── settings/ # 设定库相关代码,包括对设定区块和功能设定区块的定义。 ├── tabs/ # 浏览器扩展页面。 ├── types/ # 类型定义文件。 -├── updaters/ # 更新器代码,用于处理扩展的更新逻辑。(目前仅限 Chrome) ├── utils/ # 实用工具函数。 ├── logger.ts # 日志前缀注入。 ├── style.css # 包含 TailwindCSS 的全局样式。 diff --git a/package.json b/package.json index 84f85d99..68d2cc7c 100644 --- a/package.json +++ b/package.json @@ -26,29 +26,30 @@ "@plasmohq/messaging": "^0.6.2", "@plasmohq/storage": "^1.9.3", "@react-hooks-library/core": "^0.5.2", - "autoprefixer": "^10.4.18", + "autoprefixer": "^10.4.19", "brotli": "^1.3.3", - "dexie": "^3.2.6", + "dexie": "^3.2.7", "dexie-react-hooks": "^1.1.7", "hash-wasm": "^4.11.0", "hls.js": "^1.5.7", "media-chrome": "^2.2.5", "mpegts.js": "^1.7.3", "n-danmaku": "^2.2.1", - "plasmo": "^0.85.0", + "plasmo": "^0.85.2", "react": "18.2.0", "react-contexify": "^6.0.0", "react-dom": "18.2.0", - "react-joyride": "^2.7.4", + "react-joyride": "^2.8.0", "react-rnd": "^10.4.1", "react-shadow-root": "^6.2.0", "react-state-proxy": "^1.4.11", - "sonner": "^1.4.3", + "semver": "^7.6.0", + "sonner": "^1.4.41", "tailwindcss": "^3.4.1", "virtual-scroller": "^1.12.4" }, "devDependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.1.1", + "@ianvs/prettier-plugin-sort-imports": "^4.2.1", "@playwright/test": "^1.42.1", "@types/brotli": "^1.3.4", "@types/chrome": "^0.0.254", @@ -59,10 +60,10 @@ "@types/semver": "^7.5.8", "dotenv": "^16.4.5", "glob": "^10.3.10", - "postcss": "^8.4.35", + "postcss": "^8.4.38", "prettier": "^3.2.5", "run-script-os": "^1.1.6", - "typescript": "^5.4.2" + "typescript": "^5.4.3" }, "manifest": { "host_permissions": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78f3f06c..4fa44ed2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,17 +24,17 @@ dependencies: specifier: ^0.5.2 version: 0.5.2(react@18.2.0) autoprefixer: - specifier: ^10.4.18 - version: 10.4.18(postcss@8.4.35) + specifier: ^10.4.19 + version: 10.4.19(postcss@8.4.38) brotli: specifier: ^1.3.3 version: 1.3.3 dexie: - specifier: ^3.2.6 - version: 3.2.6(karma@6.4.3) + specifier: ^3.2.7 + version: 3.2.7 dexie-react-hooks: specifier: ^1.1.7 - version: 1.1.7(@types/react@18.2.37)(dexie@3.2.6)(react@18.2.0) + version: 1.1.7(@types/react@18.2.37)(dexie@3.2.7)(react@18.2.0) hash-wasm: specifier: ^4.11.0 version: 4.11.0 @@ -51,8 +51,8 @@ dependencies: specifier: ^2.2.1 version: 2.2.1 plasmo: - specifier: ^0.85.0 - version: 0.85.0(postcss@8.4.35)(react-dom@18.2.0)(react@18.2.0) + specifier: ^0.85.2 + version: 0.85.2(postcss@8.4.38)(react-dom@18.2.0)(react@18.2.0) react: specifier: 18.2.0 version: 18.2.0 @@ -63,8 +63,8 @@ dependencies: specifier: 18.2.0 version: 18.2.0(react@18.2.0) react-joyride: - specifier: ^2.7.4 - version: 2.7.4(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.8.0 + version: 2.8.0(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) react-rnd: specifier: ^10.4.1 version: 10.4.1(react-dom@18.2.0)(react@18.2.0) @@ -74,9 +74,12 @@ dependencies: react-state-proxy: specifier: ^1.4.11 version: 1.4.11 + semver: + specifier: ^7.6.0 + version: 7.6.0 sonner: - specifier: ^1.4.3 - version: 1.4.3(react-dom@18.2.0)(react@18.2.0) + specifier: ^1.4.41 + version: 1.4.41(react-dom@18.2.0)(react@18.2.0) tailwindcss: specifier: ^3.4.1 version: 3.4.1 @@ -86,8 +89,8 @@ dependencies: devDependencies: '@ianvs/prettier-plugin-sort-imports': - specifier: ^4.1.1 - version: 4.1.1(prettier@3.2.5) + specifier: ^4.2.1 + version: 4.2.1(prettier@3.2.5) '@playwright/test': specifier: ^1.42.1 version: 1.42.1 @@ -119,8 +122,8 @@ devDependencies: specifier: ^10.3.10 version: 10.3.10 postcss: - specifier: ^8.4.35 - version: 8.4.35 + specifier: ^8.4.38 + version: 8.4.38 prettier: specifier: ^3.2.5 version: 3.2.5 @@ -128,8 +131,8 @@ devDependencies: specifier: ^1.1.6 version: 1.1.6 typescript: - specifier: ^5.4.2 - version: 5.4.2 + specifier: ^5.4.3 + version: 5.4.3 packages: @@ -145,30 +148,30 @@ packages: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - /@babel/code-frame@7.23.5: - resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} + /@babel/code-frame@7.24.2: + resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/highlight': 7.23.4 - chalk: 2.4.2 + '@babel/highlight': 7.24.2 + picocolors: 1.0.0 - /@babel/compat-data@7.23.5: - resolution: {integrity: sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==} + /@babel/compat-data@7.24.1: + resolution: {integrity: sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==} engines: {node: '>=6.9.0'} - /@babel/core@7.24.0: - resolution: {integrity: sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==} + /@babel/core@7.24.3: + resolution: {integrity: sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==} engines: {node: '>=6.9.0'} dependencies: '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.23.5 - '@babel/generator': 7.23.6 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.1 '@babel/helper-compilation-targets': 7.23.6 - '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.0) - '@babel/helpers': 7.24.0 - '@babel/parser': 7.24.0 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.3) + '@babel/helpers': 7.24.1 + '@babel/parser': 7.24.1 '@babel/template': 7.24.0 - '@babel/traverse': 7.24.0 + '@babel/traverse': 7.24.1 '@babel/types': 7.24.0 convert-source-map: 2.0.0 debug: 4.3.4 @@ -178,8 +181,8 @@ packages: transitivePeerDependencies: - supports-color - /@babel/generator@7.23.6: - resolution: {integrity: sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==} + /@babel/generator@7.24.1: + resolution: {integrity: sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A==} engines: {node: '>=6.9.0'} dependencies: '@babel/types': 7.24.0 @@ -191,7 +194,7 @@ packages: resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/compat-data': 7.23.5 + '@babel/compat-data': 7.24.1 '@babel/helper-validator-option': 7.23.5 browserslist: 4.23.0 lru-cache: 5.1.1 @@ -214,21 +217,21 @@ packages: dependencies: '@babel/types': 7.24.0 - /@babel/helper-module-imports@7.22.15: - resolution: {integrity: sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==} + /@babel/helper-module-imports@7.24.3: + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==} engines: {node: '>=6.9.0'} dependencies: '@babel/types': 7.24.0 - /@babel/helper-module-transforms@7.23.3(@babel/core@7.24.0): + /@babel/helper-module-transforms@7.23.3(@babel/core@7.24.3): resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 '@babel/helper-environment-visitor': 7.22.20 - '@babel/helper-module-imports': 7.22.15 + '@babel/helper-module-imports': 7.24.3 '@babel/helper-simple-access': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 '@babel/helper-validator-identifier': 7.22.20 @@ -245,8 +248,8 @@ packages: dependencies: '@babel/types': 7.24.0 - /@babel/helper-string-parser@7.23.4: - resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} + /@babel/helper-string-parser@7.24.1: + resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} engines: {node: '>=6.9.0'} /@babel/helper-validator-identifier@7.22.20: @@ -257,33 +260,34 @@ packages: resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} engines: {node: '>=6.9.0'} - /@babel/helpers@7.24.0: - resolution: {integrity: sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==} + /@babel/helpers@7.24.1: + resolution: {integrity: sha512-BpU09QqEe6ZCHuIHFphEFgvNSrubve1FtyMton26ekZ85gRGi6LrTF7zArARp2YvyFxloeiRmtSCq5sjh1WqIg==} engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.24.0 - '@babel/traverse': 7.24.0 + '@babel/traverse': 7.24.1 '@babel/types': 7.24.0 transitivePeerDependencies: - supports-color - /@babel/highlight@7.23.4: - resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} + /@babel/highlight@7.24.2: + resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==} engines: {node: '>=6.9.0'} dependencies: '@babel/helper-validator-identifier': 7.22.20 chalk: 2.4.2 js-tokens: 4.0.0 + picocolors: 1.0.0 - /@babel/parser@7.24.0: - resolution: {integrity: sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==} + /@babel/parser@7.24.1: + resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==} engines: {node: '>=6.0.0'} hasBin: true dependencies: '@babel/types': 7.24.0 - /@babel/runtime@7.24.0: - resolution: {integrity: sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==} + /@babel/runtime@7.24.1: + resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==} engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.14.1 @@ -293,21 +297,21 @@ packages: resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.23.5 - '@babel/parser': 7.24.0 + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.1 '@babel/types': 7.24.0 - /@babel/traverse@7.24.0: - resolution: {integrity: sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==} + /@babel/traverse@7.24.1: + resolution: {integrity: sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.23.5 - '@babel/generator': 7.23.6 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.1 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.23.0 '@babel/helper-hoist-variables': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.24.0 + '@babel/parser': 7.24.1 '@babel/types': 7.24.0 debug: 4.3.4 globals: 11.12.0 @@ -318,20 +322,15 @@ packages: resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-string-parser': 7.23.4 + '@babel/helper-string-parser': 7.24.1 '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 - /@colors/colors@1.5.0: - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - dev: false - /@emotion/babel-plugin@11.11.0: resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} dependencies: - '@babel/helper-module-imports': 7.22.15 - '@babel/runtime': 7.24.0 + '@babel/helper-module-imports': 7.24.3 + '@babel/runtime': 7.24.1 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 '@emotion/serialize': 1.1.3 @@ -390,7 +389,7 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.1 '@emotion/babel-plugin': 11.11.0 '@emotion/cache': 11.11.0 '@emotion/serialize': 1.1.3 @@ -426,7 +425,7 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.1 '@emotion/babel-plugin': 11.11.0 '@emotion/is-prop-valid': 1.2.2 '@emotion/react': 11.11.4(@types/react@18.2.37)(react@18.2.0) @@ -693,7 +692,7 @@ packages: react-dom: '>=16.8.0' dependencies: '@floating-ui/react-dom': 1.3.0(react-dom@18.2.0)(react@18.2.0) - aria-hidden: 1.2.3 + aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) tabbable: 6.2.0 @@ -721,22 +720,22 @@ packages: /@gilbarbara/types@0.2.2: resolution: {integrity: sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==} dependencies: - type-fest: 4.12.0 + type-fest: 4.13.1 dev: false - /@ianvs/prettier-plugin-sort-imports@4.1.1(prettier@3.2.5): - resolution: {integrity: sha512-kJhXq63ngpTQ2dxgf5GasbPJWsJA3LgoOdd7WGhpUSzLgLgI4IsIzYkbJf9kmpOHe7Vdm/o3PcRA3jmizXUuAQ==} + /@ianvs/prettier-plugin-sort-imports@4.2.1(prettier@3.2.5): + resolution: {integrity: sha512-NKN1LVFWUDGDGr3vt+6Ey3qPeN/163uR1pOPAlkWpgvAqgxQ6kSdUf1F0it8aHUtKRUzEGcK38Wxd07O61d7+Q==} peerDependencies: - '@vue/compiler-sfc': '>=3.0.0' + '@vue/compiler-sfc': 2.7.x || 3.x prettier: 2 || 3 peerDependenciesMeta: '@vue/compiler-sfc': optional: true dependencies: - '@babel/core': 7.24.0 - '@babel/generator': 7.23.6 - '@babel/parser': 7.24.0 - '@babel/traverse': 7.24.0 + '@babel/core': 7.24.3 + '@babel/generator': 7.24.1 + '@babel/parser': 7.24.1 + '@babel/traverse': 7.24.1 '@babel/types': 7.24.0 prettier: 3.2.5 semver: 7.6.0 @@ -1117,7 +1116,7 @@ packages: - '@parcel/core' dev: false - /@parcel/config-default@2.9.3(@parcel/core@2.9.3)(postcss@8.4.35)(typescript@5.2.2): + /@parcel/config-default@2.9.3(@parcel/core@2.9.3)(postcss@8.4.38)(typescript@5.2.2): resolution: {integrity: sha512-tqN5tF7QnVABDZAu76co5E6N8mA9n8bxiWdK4xYyINYFIEHgX172oRTqXTnhEMjlMrdmASxvnGlbaPBaVnrCTw==} peerDependencies: '@parcel/core': ^2.9.3 @@ -1127,7 +1126,7 @@ packages: '@parcel/core': 2.9.3 '@parcel/namer-default': 2.9.3(@parcel/core@2.9.3) '@parcel/optimizer-css': 2.9.3(@parcel/core@2.9.3) - '@parcel/optimizer-htmlnano': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.35)(typescript@5.2.2) + '@parcel/optimizer-htmlnano': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.38)(typescript@5.2.2) '@parcel/optimizer-image': 2.9.3(@parcel/core@2.9.3) '@parcel/optimizer-svgo': 2.9.3(@parcel/core@2.9.3) '@parcel/optimizer-swc': 2.9.3(@parcel/core@2.9.3) @@ -1193,7 +1192,7 @@ packages: json5: 2.2.3 msgpackr: 1.10.1 nullthrows: 1.1.1 - semver: 7.5.4 + semver: 7.6.0 dev: false /@parcel/diagnostic@2.8.3: @@ -1334,7 +1333,7 @@ packages: '@parcel/fs': 2.9.3(@parcel/core@2.9.3) '@parcel/utils': 2.9.3 nullthrows: 1.1.1 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - '@parcel/core' dev: false @@ -1348,7 +1347,7 @@ packages: '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 browserslist: 4.23.0 - lightningcss: 1.24.0 + lightningcss: 1.24.1 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' @@ -1366,12 +1365,12 @@ packages: - '@parcel/core' dev: false - /@parcel/optimizer-htmlnano@2.9.3(@parcel/core@2.9.3)(postcss@8.4.35)(typescript@5.2.2): + /@parcel/optimizer-htmlnano@2.9.3(@parcel/core@2.9.3)(postcss@8.4.38)(typescript@5.2.2): resolution: {integrity: sha512-9g/KBck3c6DokmJfvJ5zpHFBiCSolaGrcsTGx8C3YPdCTVTI9P1TDCwUxvAr4LjpcIRSa82wlLCI+nF6sSgxKA==} engines: {node: '>= 12.0.0', parcel: ^2.9.3} dependencies: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) - htmlnano: 2.1.0(postcss@8.4.35)(svgo@2.8.0)(typescript@5.2.2) + htmlnano: 2.1.0(postcss@8.4.38)(svgo@2.8.0)(typescript@5.2.2) nullthrows: 1.1.1 posthtml: 0.16.6 svgo: 2.8.0 @@ -1420,7 +1419,7 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 - '@swc/core': 1.4.6 + '@swc/core': 1.4.8 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' @@ -1457,7 +1456,7 @@ packages: '@parcel/types': 2.9.3(@parcel/core@2.9.3) '@parcel/utils': 2.9.3 '@parcel/workers': 2.9.3(@parcel/core@2.9.3) - semver: 7.5.4 + semver: 7.6.0 dev: false /@parcel/packager-css@2.9.3(@parcel/core@2.9.3): @@ -1652,7 +1651,7 @@ packages: browserslist: 4.23.0 json5: 2.2.3 nullthrows: 1.1.1 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - '@parcel/core' dev: false @@ -1666,7 +1665,7 @@ packages: '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 browserslist: 4.23.0 - lightningcss: 1.24.0 + lightningcss: 1.24.1 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' @@ -1694,7 +1693,7 @@ packages: posthtml: 0.16.6 posthtml-parser: 0.10.2 posthtml-render: 3.0.0 - semver: 7.5.4 + semver: 7.6.0 srcset: 4.0.0 transitivePeerDependencies: - '@parcel/core' @@ -1734,11 +1733,11 @@ packages: '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 '@parcel/workers': 2.9.3(@parcel/core@2.9.3) - '@swc/helpers': 0.5.6 + '@swc/helpers': 0.5.7 browserslist: 4.23.0 nullthrows: 1.1.1 regenerator-runtime: 0.13.11 - semver: 7.5.4 + semver: 7.6.0 dev: false /@parcel/transformer-json@2.9.3(@parcel/core@2.9.3): @@ -1773,7 +1772,7 @@ packages: clone: 2.1.2 nullthrows: 1.1.1 postcss-value-parser: 4.2.0 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - '@parcel/core' dev: false @@ -1788,7 +1787,7 @@ packages: posthtml: 0.16.6 posthtml-parser: 0.10.2 posthtml-render: 3.0.0 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - '@parcel/core' dev: false @@ -1819,7 +1818,7 @@ packages: dependencies: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 - sass: 1.71.1 + sass: 1.72.0 transitivePeerDependencies: - '@parcel/core' dev: false @@ -1848,7 +1847,7 @@ packages: posthtml: 0.16.6 posthtml-parser: 0.10.2 posthtml-render: 3.0.0 - semver: 7.5.4 + semver: 7.6.0 transitivePeerDependencies: - '@parcel/core' dev: false @@ -2251,11 +2250,11 @@ packages: - '@parcel/core' dev: false - /@plasmohq/parcel-config@0.40.3(postcss@8.4.35)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2): - resolution: {integrity: sha512-dYVXJZKRtYGo6oFVirXFw295HAC58F8LrJqq0iVLqvZDo7fr/XPLxnBmSz1MJHCiDsiWM1/j1iywwHlAdHqbJQ==} + /@plasmohq/parcel-config@0.40.5(postcss@8.4.38)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2): + resolution: {integrity: sha512-2uXGCURaKazxG8UKGnwZCQ0cfi9sns9JkyC5ERVQJqTmjE81Rwzd/QxjuoufQ2/kvcg3klb3952X1znmD7prlQ==} dependencies: '@parcel/compressor-raw': 2.9.3(@parcel/core@2.9.3) - '@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.35)(typescript@5.2.2) + '@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.38)(typescript@5.2.2) '@parcel/core': 2.9.3 '@parcel/optimizer-data-url': 2.9.3(@parcel/core@2.9.3) '@parcel/reporter-bundle-buddy': 2.9.3(@parcel/core@2.9.3) @@ -2281,8 +2280,8 @@ packages: '@plasmohq/parcel-optimizer-encapsulate': 0.0.7 '@plasmohq/parcel-optimizer-es': 0.4.0 '@plasmohq/parcel-packager': 0.6.14 - '@plasmohq/parcel-resolver': 0.13.2 - '@plasmohq/parcel-resolver-post': 0.4.3(postcss@8.4.35) + '@plasmohq/parcel-resolver': 0.14.0 + '@plasmohq/parcel-resolver-post': 0.4.4(postcss@8.4.38) '@plasmohq/parcel-runtime': 0.23.1 '@plasmohq/parcel-transformer-inject-env': 0.2.11 '@plasmohq/parcel-transformer-inline-css': 0.3.11 @@ -2418,8 +2417,8 @@ packages: nullthrows: 1.1.1 dev: false - /@plasmohq/parcel-resolver-post@0.4.3(postcss@8.4.35): - resolution: {integrity: sha512-+KxdAOyBJNK7wxLUbLhx0d4AWQg2trcCK8rwOSNL8JP0OgtSDaOFa2NqCTFwuccGco4PzmK+27U17LWSGTFAOQ==} + /@plasmohq/parcel-resolver-post@0.4.4(postcss@8.4.38): + resolution: {integrity: sha512-n39U5z2aGAfCDFydpvEDXx0MWtqYwh0+aX4QS49/IsmZMM1Ra+GnHs/gfeJz0jtN83EytlbwSoDcXRkORx9rIQ==} engines: {parcel: '>= 2.7.0'} dependencies: '@parcel/core': 2.9.3 @@ -2427,7 +2426,7 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/types': 2.9.3(@parcel/core@2.9.3) '@parcel/utils': 2.9.3 - tsup: 7.2.0(postcss@8.4.35)(typescript@5.2.2) + tsup: 7.2.0(postcss@8.4.38)(typescript@5.2.2) typescript: 5.2.2 transitivePeerDependencies: - '@swc/core' @@ -2436,8 +2435,8 @@ packages: - ts-node dev: false - /@plasmohq/parcel-resolver@0.13.2: - resolution: {integrity: sha512-JVXk65c5g5rOci9xmuvEqpemOFc6yTlGO1A1LCllFeByl2hBszRCBBSNp9wsaes2gQIbClgzFjbOSijKV3acNw==} + /@plasmohq/parcel-resolver@0.14.0: + resolution: {integrity: sha512-OPGFiv2SxDEJl9sNPKfjkQ3QaqKOzSDx8E85Bq9FCOKCj+EWTPfoeUOAuMkHY/ArcvDBhWAo3Zu66f2U7iPEGQ==} engines: {parcel: '>= 2.7.0'} dependencies: '@parcel/core': 2.9.3 @@ -2629,105 +2628,101 @@ packages: engines: {node: '>=14.16'} dev: false - /@socket.io/component-emitter@3.1.0: - resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} - dev: false - - /@svgr/babel-plugin-add-jsx-attribute@6.5.1(@babel/core@7.24.0): + /@svgr/babel-plugin-add-jsx-attribute@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.24.0): + /@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.24.3): resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} engines: {node: '>=14'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.24.0): + /@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.24.3): resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} engines: {node: '>=14'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-replace-jsx-attribute-value@6.5.1(@babel/core@7.24.0): + /@svgr/babel-plugin-replace-jsx-attribute-value@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-8DPaVVE3fd5JKuIC29dqyMB54sA6mfgki2H2+swh+zNJoynC8pMPzOkidqHOSc6Wj032fhl8Z0TVn1GiPpAiJg==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-svg-dynamic-title@6.5.1(@babel/core@7.24.0): + /@svgr/babel-plugin-svg-dynamic-title@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-FwOEi0Il72iAzlkaHrlemVurgSQRDFbk0OC8dSvD5fSBPHltNh7JtLsxmZUhjYBZo2PpcU/RJvvi6Q0l7O7ogw==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-svg-em-dimensions@6.5.1(@babel/core@7.24.0): + /@svgr/babel-plugin-svg-em-dimensions@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-gWGsiwjb4tw+ITOJ86ndY/DZZ6cuXMNE/SjcDRg+HLuCmwpcjOktwRF9WgAiycTqJD/QXqL2f8IzE2Rzh7aVXA==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-transform-react-native-svg@6.5.1(@babel/core@7.24.0): + /@svgr/babel-plugin-transform-react-native-svg@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-2jT3nTayyYP7kI6aGutkyfJ7UMGtuguD72OjeGLwVNyfPRBD8zQthlvL+fAbAKk5n9ZNcvFkp/b1lZ7VsYqVJg==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-plugin-transform-svg-component@6.5.1(@babel/core@7.24.0): + /@svgr/babel-plugin-transform-svg-component@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-a1p6LF5Jt33O3rZoVRBqdxL350oge54iZWHNI6LJB5tQ7EelvD/Mb1mfBiZNAan0dt4i3VArkFRjA4iObuNykQ==} engines: {node: '>=12'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 + '@babel/core': 7.24.3 dev: false - /@svgr/babel-preset@6.5.1(@babel/core@7.24.0): + /@svgr/babel-preset@6.5.1(@babel/core@7.24.3): resolution: {integrity: sha512-6127fvO/FF2oi5EzSQOAjo1LE3OtNVh11R+/8FXa+mHx1ptAaS4cknIjnUA7e6j6fwGGJ17NzaTJFUwOV2zwCw==} engines: {node: '>=10'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.24.0 - '@svgr/babel-plugin-add-jsx-attribute': 6.5.1(@babel/core@7.24.0) - '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.24.0) - '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.24.0) - '@svgr/babel-plugin-replace-jsx-attribute-value': 6.5.1(@babel/core@7.24.0) - '@svgr/babel-plugin-svg-dynamic-title': 6.5.1(@babel/core@7.24.0) - '@svgr/babel-plugin-svg-em-dimensions': 6.5.1(@babel/core@7.24.0) - '@svgr/babel-plugin-transform-react-native-svg': 6.5.1(@babel/core@7.24.0) - '@svgr/babel-plugin-transform-svg-component': 6.5.1(@babel/core@7.24.0) + '@babel/core': 7.24.3 + '@svgr/babel-plugin-add-jsx-attribute': 6.5.1(@babel/core@7.24.3) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.24.3) + '@svgr/babel-plugin-replace-jsx-attribute-value': 6.5.1(@babel/core@7.24.3) + '@svgr/babel-plugin-svg-dynamic-title': 6.5.1(@babel/core@7.24.3) + '@svgr/babel-plugin-svg-em-dimensions': 6.5.1(@babel/core@7.24.3) + '@svgr/babel-plugin-transform-react-native-svg': 6.5.1(@babel/core@7.24.3) + '@svgr/babel-plugin-transform-svg-component': 6.5.1(@babel/core@7.24.3) dev: false /@svgr/core@6.5.1: resolution: {integrity: sha512-/xdLSWxK5QkqG524ONSjvg3V/FkNyCv538OIBdQqPNaAta3AsXj/Bd2FbvR87yMbXO2hFSWiAe/Q6IkVPDw+mw==} engines: {node: '>=10'} dependencies: - '@babel/core': 7.24.0 - '@svgr/babel-preset': 6.5.1(@babel/core@7.24.0) + '@babel/core': 7.24.3 + '@svgr/babel-preset': 6.5.1(@babel/core@7.24.3) '@svgr/plugin-jsx': 6.5.1(@svgr/core@6.5.1) camelcase: 6.3.0 cosmiconfig: 7.1.0 @@ -2749,8 +2744,8 @@ packages: peerDependencies: '@svgr/core': ^6.0.0 dependencies: - '@babel/core': 7.24.0 - '@svgr/babel-preset': 6.5.1(@babel/core@7.24.0) + '@babel/core': 7.24.3 + '@svgr/babel-preset': 6.5.1(@babel/core@7.24.3) '@svgr/core': 6.5.1 '@svgr/hast-util-to-babel-ast': 6.5.1 svg-parser: 2.0.4 @@ -2779,8 +2774,8 @@ packages: dev: false optional: true - /@swc/core-darwin-arm64@1.4.6: - resolution: {integrity: sha512-bpggpx/BfLFyy48aUKq1PsNUxb7J6CINlpAUk0V4yXfmGnpZH80Gp1pM3GkFDQyCfq7L7IpjPrIjWQwCrL4hYw==} + /@swc/core-darwin-arm64@1.4.8: + resolution: {integrity: sha512-hhQCffRTgzpTIbngSnC30vV6IJVTI9FFBF954WEsshsecVoCGFiMwazBbrkLG+RwXENTrMhgeREEFh6R3KRgKQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] @@ -2797,8 +2792,8 @@ packages: dev: false optional: true - /@swc/core-darwin-x64@1.4.6: - resolution: {integrity: sha512-vJn+/ZuBTg+vtNkcmgZdH6FQpa0hFVdnB9bAeqYwKkyqP15zaPe6jfC+qL2y/cIeC7ASvHXEKrnCZgBLxfVQ9w==} + /@swc/core-darwin-x64@1.4.8: + resolution: {integrity: sha512-P3ZBw8Jr8rKhY/J8d+6WqWriqngGTgHwtFeJ8MIakQJTbdYbFgXSZxcvDiERg3psbGeFXaUaPI0GO6BXv9k/OQ==} engines: {node: '>=10'} cpu: [x64] os: [darwin] @@ -2815,8 +2810,8 @@ packages: dev: false optional: true - /@swc/core-linux-arm-gnueabihf@1.4.6: - resolution: {integrity: sha512-hEmYcB/9XBAl02MtuVHszhNjQpjBzhk/NFulnU33tBMbNZpy2TN5yTsitezMq090QXdDz8sKIALApDyg07ZR8g==} + /@swc/core-linux-arm-gnueabihf@1.4.8: + resolution: {integrity: sha512-PP9JIJt19bUWhAGcQW6qMwTjZOcMyzkvZa0/LWSlDm0ORYVLmDXUoeQbGD3e0Zju9UiZxyulnpjEN0ZihJgPTA==} engines: {node: '>=10'} cpu: [arm] os: [linux] @@ -2833,8 +2828,8 @@ packages: dev: false optional: true - /@swc/core-linux-arm64-gnu@1.4.6: - resolution: {integrity: sha512-/UCYIVoGpm2YVvGHZM2QOA3dexa28BjcpLAIYnoCbgH5f7ulDhE8FAIO/9pasj+kixDBsdqewHfsNXFYlgGJjQ==} + /@swc/core-linux-arm64-gnu@1.4.8: + resolution: {integrity: sha512-HvEWnwKHkoVUr5iftWirTApFJ13hGzhAY2CMw4lz9lur2m+zhPviRRED0FCI6T95Knpv7+8eUOr98Z7ctrG6DQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] @@ -2851,8 +2846,8 @@ packages: dev: false optional: true - /@swc/core-linux-arm64-musl@1.4.6: - resolution: {integrity: sha512-LGQsKJ8MA9zZ8xHCkbGkcPSmpkZL2O7drvwsGKynyCttHhpwVjj9lguhD4DWU3+FWIsjvho5Vu0Ggei8OYi/Lw==} + /@swc/core-linux-arm64-musl@1.4.8: + resolution: {integrity: sha512-kY8+qa7k/dEeBq9p0Hrta18QnJPpsiJvDQSLNaTIFpdM3aEM9zbkshWz8gaX5VVGUEALowCBUWqmzO4VaqM+2w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] @@ -2869,8 +2864,8 @@ packages: dev: false optional: true - /@swc/core-linux-x64-gnu@1.4.6: - resolution: {integrity: sha512-10JL2nLIreMQDKvq2TECnQe5fCuoqBHu1yW8aChqgHUyg9d7gfZX/kppUsuimqcgRBnS0AjTDAA+JF6UsG/2Yg==} + /@swc/core-linux-x64-gnu@1.4.8: + resolution: {integrity: sha512-0WWyIw432wpO/zeGblwq4f2YWam4pn8Z/Ig4KzHMgthR/KmiLU3f0Z7eo45eVmq5vcU7Os1zi/Zb65OOt09q/w==} engines: {node: '>=10'} cpu: [x64] os: [linux] @@ -2887,8 +2882,8 @@ packages: dev: false optional: true - /@swc/core-linux-x64-musl@1.4.6: - resolution: {integrity: sha512-EGyjFVzVY6Do89x8sfah7I3cuP4MwtwzmA6OlfD/KASqfCFf5eIaEBMbajgR41bVfMV7lK72lwAIea5xEyq1AQ==} + /@swc/core-linux-x64-musl@1.4.8: + resolution: {integrity: sha512-p4yxvVS05rBNCrBaSTa20KK88vOwtg8ifTW7ec/yoab0bD5EwzzB8KbDmLLxE6uziFa0sdjF0dfRDwSZPex37Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] @@ -2905,8 +2900,8 @@ packages: dev: false optional: true - /@swc/core-win32-arm64-msvc@1.4.6: - resolution: {integrity: sha512-gfW9AuXvwSyK07Vb8Y8E9m2oJZk21WqcD+X4BZhkbKB0TCZK0zk1j/HpS2UFlr1JB2zPKPpSWLU3ll0GEHRG2A==} + /@swc/core-win32-arm64-msvc@1.4.8: + resolution: {integrity: sha512-jKuXihxAaqUnbFfvPxtmxjdJfs87F1GdBf33il+VUmSyWCP4BE6vW+/ReDAe8sRNsKyrZ3UH1vI5q1n64csBUA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] @@ -2923,8 +2918,8 @@ packages: dev: false optional: true - /@swc/core-win32-ia32-msvc@1.4.6: - resolution: {integrity: sha512-ZuQm81FhhvNVYtVb9GfZ+Du6e7fZlkisWvuCeBeRiyseNt1tcrQ8J3V67jD2nxje8CVXrwG3oUIbPcybv2rxfQ==} + /@swc/core-win32-ia32-msvc@1.4.8: + resolution: {integrity: sha512-O0wT4AGHrX8aBeH6c2ADMHgagAJc5Kf6W48U5moyYDAkkVnKvtSc4kGhjWhe1Yl0sI0cpYh2In2FxvYsb44eWw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] @@ -2941,8 +2936,8 @@ packages: dev: false optional: true - /@swc/core-win32-x64-msvc@1.4.6: - resolution: {integrity: sha512-UagPb7w5V0uzWSjrXwOavGa7s9iv3wrVdEgWy+/inm0OwY4lj3zpK9qDnMWAwYLuFwkI3UG4Q3dH8wD+CUUcjw==} + /@swc/core-win32-x64-msvc@1.4.8: + resolution: {integrity: sha512-C2AYc3A2o+ECciqsJWRgIpp83Vk5EaRzHe7ed/xOWzVd0MsWR+fweEsyOjlmzHfpUxJSi46Ak3/BIZJlhZbXbg==} engines: {node: '>=10'} cpu: [x64] os: [win32] @@ -2960,7 +2955,7 @@ packages: '@swc/helpers': optional: true dependencies: - '@swc/types': 0.1.5 + '@swc/types': 0.1.6 optionalDependencies: '@swc/core-darwin-arm64': 1.3.82 '@swc/core-darwin-x64': 1.3.82 @@ -2974,8 +2969,8 @@ packages: '@swc/core-win32-x64-msvc': 1.3.82 dev: false - /@swc/core@1.4.6: - resolution: {integrity: sha512-A7iK9+1qzTCIuc3IYcS8gPHCm9bZVKUJrfNnwveZYyo6OFp3jLno4WOM2yBy5uqedgYATEiWgBYHKq37KrU6IA==} + /@swc/core@1.4.8: + resolution: {integrity: sha512-uY2RSJcFPgNOEg12RQZL197LZX+MunGiKxsbxmh22VfVxrOYGRvh4mPANFlrD1yb38CgmW1wI6YgIi8LkIwmWg==} engines: {node: '>=10'} requiresBuild: true peerDependencies: @@ -2985,32 +2980,34 @@ packages: optional: true dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.5 + '@swc/types': 0.1.6 optionalDependencies: - '@swc/core-darwin-arm64': 1.4.6 - '@swc/core-darwin-x64': 1.4.6 - '@swc/core-linux-arm-gnueabihf': 1.4.6 - '@swc/core-linux-arm64-gnu': 1.4.6 - '@swc/core-linux-arm64-musl': 1.4.6 - '@swc/core-linux-x64-gnu': 1.4.6 - '@swc/core-linux-x64-musl': 1.4.6 - '@swc/core-win32-arm64-msvc': 1.4.6 - '@swc/core-win32-ia32-msvc': 1.4.6 - '@swc/core-win32-x64-msvc': 1.4.6 + '@swc/core-darwin-arm64': 1.4.8 + '@swc/core-darwin-x64': 1.4.8 + '@swc/core-linux-arm-gnueabihf': 1.4.8 + '@swc/core-linux-arm64-gnu': 1.4.8 + '@swc/core-linux-arm64-musl': 1.4.8 + '@swc/core-linux-x64-gnu': 1.4.8 + '@swc/core-linux-x64-musl': 1.4.8 + '@swc/core-win32-arm64-msvc': 1.4.8 + '@swc/core-win32-ia32-msvc': 1.4.8 + '@swc/core-win32-x64-msvc': 1.4.8 dev: false /@swc/counter@0.1.3: resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} dev: false - /@swc/helpers@0.5.6: - resolution: {integrity: sha512-aYX01Ke9hunpoCexYAgQucEpARGQ5w/cqHFrIR+e9gdKb1QWTsVJuTJ2ozQzIAxLyRQe/m+2RqzkyOOGiMKRQA==} + /@swc/helpers@0.5.7: + resolution: {integrity: sha512-BVvNZhx362+l2tSwSuyEUV4h7+jk9raNdoTSdLfwTshXJSaGmYKluGRJznziCI3KX02Z19DdsQrdfrpXAU3Hfg==} dependencies: tslib: 2.6.2 dev: false - /@swc/types@0.1.5: - resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==} + /@swc/types@0.1.6: + resolution: {integrity: sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==} + dependencies: + '@swc/counter': 0.1.3 dev: false /@szmarczak/http-timer@5.0.1: @@ -3038,16 +3035,6 @@ packages: '@types/har-format': 1.2.15 dev: true - /@types/cookie@0.4.1: - resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} - dev: false - - /@types/cors@2.8.17: - resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} - dependencies: - '@types/node': 20.11.23 - dev: false - /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: false @@ -3089,6 +3076,7 @@ packages: resolution: {integrity: sha512-ZUarKKfQuRILSNYt32FuPL20HS7XwNT7/uRwSV8tiHWfyyVwDLYZNF6DZKc2bove++pgfsXn9sUwII/OsQ82cQ==} dependencies: undici-types: 5.26.5 + dev: true /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -3120,10 +3108,10 @@ packages: /@vue/compiler-core@3.3.4: resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==} dependencies: - '@babel/parser': 7.24.0 + '@babel/parser': 7.24.1 '@vue/shared': 3.3.4 estree-walker: 2.0.2 - source-map-js: 1.0.2 + source-map-js: 1.2.0 dev: false /@vue/compiler-dom@3.3.4: @@ -3136,7 +3124,7 @@ packages: /@vue/compiler-sfc@3.3.4: resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==} dependencies: - '@babel/parser': 7.24.0 + '@babel/parser': 7.24.1 '@vue/compiler-core': 3.3.4 '@vue/compiler-dom': 3.3.4 '@vue/compiler-ssr': 3.3.4 @@ -3144,8 +3132,8 @@ packages: '@vue/shared': 3.3.4 estree-walker: 2.0.2 magic-string: 0.30.8 - postcss: 8.4.35 - source-map-js: 1.0.2 + postcss: 8.4.38 + source-map-js: 1.2.0 dev: false /@vue/compiler-ssr@3.3.4: @@ -3158,7 +3146,7 @@ packages: /@vue/reactivity-transform@3.3.4: resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==} dependencies: - '@babel/parser': 7.24.0 + '@babel/parser': 7.24.1 '@vue/compiler-core': 3.3.4 '@vue/shared': 3.3.4 estree-walker: 2.0.2 @@ -3204,14 +3192,6 @@ packages: resolution: {integrity: sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==} dev: false - /accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - dev: false - /acorn@8.11.3: resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} engines: {node: '>=0.4.0'} @@ -3269,8 +3249,8 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: false - /aria-hidden@1.2.3: - resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} + /aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} dependencies: tslib: 2.6.2 @@ -3287,19 +3267,19 @@ packages: engines: {node: '>=8'} dev: false - /autoprefixer@10.4.18(postcss@8.4.35): - resolution: {integrity: sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==} + /autoprefixer@10.4.19(postcss@8.4.38): + resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 dependencies: browserslist: 4.23.0 - caniuse-lite: 1.0.30001596 + caniuse-lite: 1.0.30001599 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.0 - postcss: 8.4.35 + postcss: 8.4.38 postcss-value-parser: 4.2.0 dev: false @@ -3317,7 +3297,7 @@ packages: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.1 cosmiconfig: 7.1.0 resolve: 1.22.8 dev: false @@ -3331,19 +3311,19 @@ packages: dev: false optional: true - /bare-fs@2.2.1: - resolution: {integrity: sha512-+CjmZANQDFZWy4PGbVdmALIwmt33aJg8qTkVjClU6X4WmZkTPBDxRHiBn7fpqEWEfF3AC2io++erpViAIQbSjg==} + /bare-fs@2.2.2: + resolution: {integrity: sha512-X9IqgvyB0/VA5OZJyb5ZstoN62AzD7YxVGog13kkfYWYqJYcK0kcqLZ6TrmH5qr4/8//ejVcX4x/a0UvaogXmA==} requiresBuild: true dependencies: bare-events: 2.2.1 - bare-os: 2.2.0 + bare-os: 2.2.1 bare-path: 2.1.0 streamx: 2.16.1 dev: false optional: true - /bare-os@2.2.0: - resolution: {integrity: sha512-hD0rOPfYWOMpVirTACt4/nK8mC55La12K5fY1ij8HAdfQakD62M+H4o4tpfKzVGLgRDTuk3vjA4GqGXXCeFbag==} + /bare-os@2.2.1: + resolution: {integrity: sha512-OwPyHgBBMkhC29Hl3O4/YfxW9n7mdTr2+SsO29XBWKKJsbgj3mnorDB80r5TiCQgQstgE5ga1qNYrpes6NvX2w==} requiresBuild: true dev: false optional: true @@ -3352,7 +3332,7 @@ packages: resolution: {integrity: sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==} requiresBuild: true dependencies: - bare-os: 2.2.0 + bare-os: 2.2.1 dev: false optional: true @@ -3366,13 +3346,8 @@ packages: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false - /base64id@2.0.0: - resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} - engines: {node: ^4.5.0 || >= 5.9} - dev: false - - /binary-extensions@2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + /binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} dev: false @@ -3388,37 +3363,10 @@ packages: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} dev: false - /body-parser@1.20.2: - resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.11.0 - raw-body: 2.5.2 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - dev: false - /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} dev: false - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - dev: false - /brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: @@ -3442,8 +3390,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001596 - electron-to-chromium: 1.4.699 + caniuse-lite: 1.0.30001599 + electron-to-chromium: 1.4.713 node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.22.1) dev: false @@ -3453,8 +3401,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001596 - electron-to-chromium: 1.4.699 + caniuse-lite: 1.0.30001599 + electron-to-chromium: 1.4.713 node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.23.0) @@ -3482,11 +3430,6 @@ packages: load-tsconfig: 0.2.5 dev: false - /bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - dev: false - /cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3506,7 +3449,7 @@ packages: http-cache-semantics: 4.1.1 keyv: 4.5.4 mimic-response: 4.0.0 - normalize-url: 8.0.0 + normalize-url: 8.0.1 responselike: 3.0.0 dev: false @@ -3518,7 +3461,7 @@ packages: es-errors: 1.3.0 function-bind: 1.1.2 get-intrinsic: 1.2.4 - set-function-length: 1.2.1 + set-function-length: 1.2.2 dev: false /callsites@3.1.0: @@ -3536,8 +3479,8 @@ packages: engines: {node: '>=10'} dev: false - /caniuse-lite@1.0.30001596: - resolution: {integrity: sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==} + /caniuse-lite@1.0.30001599: + resolution: {integrity: sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA==} /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -3613,14 +3556,6 @@ packages: engines: {node: '>= 12'} dev: false - /cliui@7.0.4: - resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - dev: false - /clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -3688,10 +3623,6 @@ packages: engines: {node: '>= 10'} dev: false - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: false - /config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} dependencies: @@ -3699,28 +3630,11 @@ packages: proto-list: 1.2.4 dev: false - /connect@3.7.0: - resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} - engines: {node: '>= 0.10.0'} - dependencies: - debug: 2.6.9 - finalhandler: 1.1.2 - parseurl: 1.3.3 - utils-merge: 1.0.1 - transitivePeerDependencies: - - supports-color - dev: false - /content-security-policy-parser@0.4.1: resolution: {integrity: sha512-NNJS8XPnx3OKr/CUOSwDSJw+lWTrZMYnclLKj0Y9CYOfJNJTWLFGPg3u2hYgbXMXKVRkZR2fbyReNQ1mUff/Qg==} engines: {node: '>=8.0.0'} dev: false - /content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - dev: false - /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} dev: false @@ -3728,25 +3642,12 @@ packages: /convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - /cookie@0.4.2: - resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} - engines: {node: '>= 0.6'} - dev: false - /copy-anything@2.0.6: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} dependencies: is-what: 3.14.1 dev: false - /cors@2.8.5: - resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} - engines: {node: '>= 0.10'} - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - dev: false - /cosmiconfig@7.1.0: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} @@ -3812,7 +3713,7 @@ packages: engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} dependencies: mdn-data: 2.0.30 - source-map-js: 1.0.2 + source-map-js: 1.2.0 dev: false /css-what@6.1.0: @@ -3836,26 +3737,6 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - /custom-event@1.0.1: - resolution: {integrity: sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==} - dev: false - - /date-format@4.0.14: - resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==} - engines: {node: '>=4.0'} - dev: false - - /debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.0.0 - dev: false - /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -3913,33 +3794,23 @@ packages: gopd: 1.0.1 dev: false - /depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dev: false - /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} dev: false - /destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dev: false - /detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} hasBin: true dev: false - /detect-libc@2.0.2: - resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + /detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} dev: false - /dexie-react-hooks@1.1.7(@types/react@18.2.37)(dexie@3.2.6)(react@18.2.0): + /dexie-react-hooks@1.1.7(@types/react@18.2.37)(dexie@3.2.7)(react@18.2.0): resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==} peerDependencies: '@types/react': '>=16' @@ -3947,21 +3818,13 @@ packages: react: '>=16' dependencies: '@types/react': 18.2.37 - dexie: 3.2.6(karma@6.4.3) + dexie: 3.2.7 react: 18.2.0 dev: false - /dexie@3.2.6(karma@6.4.3): - resolution: {integrity: sha512-R9VzQ27/cncQymoAeD1kfu66NUrdxcnMNXVfEoFLnQ+apVVbS4++veUcSGxft9V++zaeiLkMAREOMf8EwgR/Vw==} + /dexie@3.2.7: + resolution: {integrity: sha512-2a+BXvVhY5op+smDRLxeBAivE7YcYaneXJ1la3HOkUfX9zKkE/AJ8CNgjiXbtXepFyFmJNGSbmjOwqbT749r/w==} engines: {node: '>=6.0'} - dependencies: - karma-safari-launcher: 1.0.0(karma@6.4.3) - transitivePeerDependencies: - - karma - dev: false - - /di@0.0.1: - resolution: {integrity: sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==} dev: false /didyoumean@1.2.2: @@ -3979,15 +3842,6 @@ packages: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} dev: false - /dom-serialize@2.2.1: - resolution: {integrity: sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==} - dependencies: - custom-event: 1.0.1 - ent: 2.2.0 - extend: 3.0.2 - void-elements: 2.0.1 - dev: false - /dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} dependencies: @@ -4042,12 +3896,8 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - /ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - dev: false - - /electron-to-chromium@1.4.699: - resolution: {integrity: sha512-I7q3BbQi6e4tJJN5CRcyvxhK0iJb34TV8eJQcgh+fR2fQ8miMgZcEInckCo1U9exDHbfz7DLDnFn8oqH/VcRKw==} + /electron-to-chromium@1.4.713: + resolution: {integrity: sha512-vDarADhwntXiULEdmWd77g2dV6FrNGa8ecAC29MZ4TwPut2fvosD0/5sJd1qWNNe8HcJFAC+F5Lf9jW1NPtWmw==} /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4055,46 +3905,12 @@ packages: /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - /encodeurl@1.0.2: - resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} - engines: {node: '>= 0.8'} - dev: false - /end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: once: 1.4.0 dev: false - /engine.io-parser@5.2.2: - resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} - engines: {node: '>=10.0.0'} - dev: false - - /engine.io@6.5.4: - resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} - engines: {node: '>=10.2.0'} - dependencies: - '@types/cookie': 0.4.1 - '@types/cors': 2.8.17 - '@types/node': 20.11.23 - accepts: 1.3.8 - base64id: 2.0.0 - cookie: 0.4.2 - cors: 2.8.5 - debug: 4.3.4 - engine.io-parser: 5.2.2 - ws: 8.11.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - - /ent@2.2.0: - resolution: {integrity: sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==} - dev: false - /entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: false @@ -4174,10 +3990,6 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} - /escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - dev: false - /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -4202,10 +4014,6 @@ packages: '@types/estree': 1.0.5 dev: false - /eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - dev: false - /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4231,10 +4039,6 @@ packages: engines: {node: '>=6'} dev: false - /extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - dev: false - /external-editor@3.1.0: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} @@ -4288,39 +4092,10 @@ packages: to-regex-range: 5.0.1 dev: false - /finalhandler@1.1.2: - resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} - engines: {node: '>= 0.8'} - dependencies: - debug: 2.6.9 - encodeurl: 1.0.2 - escape-html: 1.0.3 - on-finished: 2.3.0 - parseurl: 1.3.3 - statuses: 1.5.0 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - dev: false - /find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} dev: false - /flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - dev: false - - /follow-redirects@1.15.5: - resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false - /foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} @@ -4374,19 +4149,6 @@ packages: universalify: 2.0.1 dev: false - /fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - dev: false - - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: false - /fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4411,11 +4173,6 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - /get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - dev: false - /get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -4424,7 +4181,7 @@ packages: function-bind: 1.1.2 has-proto: 1.0.3 has-symbols: 1.0.3 - hasown: 2.0.1 + hasown: 2.0.2 dev: false /get-port@7.0.0: @@ -4466,17 +4223,6 @@ packages: minipass: 7.0.4 path-scurry: 1.10.1 - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: false - /globals@11.12.0: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} @@ -4588,8 +4334,8 @@ packages: resolution: {integrity: sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==} dev: false - /hasown@2.0.1: - resolution: {integrity: sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==} + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 @@ -4609,7 +4355,7 @@ packages: react-is: 16.13.1 dev: false - /htmlnano@2.1.0(postcss@8.4.35)(svgo@2.8.0)(typescript@5.2.2): + /htmlnano@2.1.0(postcss@8.4.38)(svgo@2.8.0)(typescript@5.2.2): resolution: {integrity: sha512-jVGRE0Ep9byMBKEu0Vxgl8dhXYOUk0iNQ2pjsG+BcRB0u0oDF5A9p/iBGMg/PGKYUyMD0OAGu8dVT5Lzj8S58g==} peerDependencies: cssnano: ^6.0.0 @@ -4639,7 +4385,7 @@ packages: optional: true dependencies: cosmiconfig: 8.3.6(typescript@5.2.2) - postcss: 8.4.35 + postcss: 8.4.38 posthtml: 0.16.6 svgo: 2.8.0 timsort: 0.3.0 @@ -4660,28 +4406,6 @@ packages: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} dev: false - /http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - dev: false - - /http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.5 - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - dev: false - /http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} @@ -4740,13 +4464,6 @@ packages: resolve-from: 4.0.0 dev: false - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - dev: false - /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: false @@ -4788,13 +4505,13 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} dependencies: - binary-extensions: 2.2.0 + binary-extensions: 2.3.0 dev: false /is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} dependencies: - hasown: 2.0.1 + hasown: 2.0.2 dev: false /is-extglob@2.1.1: @@ -4923,7 +4640,7 @@ packages: resolution: {integrity: sha512-h9WqLkTVpBbiaPb5OmeUpz/FBLS/kvIJw4oRCPiEisIu2WjMh+aai0QIY2LoOhRFx5r92taGLcerIrzxKBAP6g==} engines: {node: '>=16'} dependencies: - '@babel/runtime': 7.24.0 + '@babel/runtime': 7.24.1 '@types/json-schema': 7.0.15 ts-algebra: 1.2.2 dev: false @@ -4933,12 +4650,6 @@ packages: engines: {node: '>=6'} hasBin: true - /jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - optionalDependencies: - graceful-fs: 4.2.11 - dev: false - /jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} dependencies: @@ -4947,50 +4658,6 @@ packages: graceful-fs: 4.2.11 dev: false - /karma-safari-launcher@1.0.0(karma@6.4.3): - resolution: {integrity: sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==} - peerDependencies: - karma: '>=0.9' - dependencies: - karma: 6.4.3 - dev: false - - /karma@6.4.3: - resolution: {integrity: sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==} - engines: {node: '>= 10'} - hasBin: true - dependencies: - '@colors/colors': 1.5.0 - body-parser: 1.20.2 - braces: 3.0.2 - chokidar: 3.6.0 - connect: 3.7.0 - di: 0.0.1 - dom-serialize: 2.2.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - http-proxy: 1.18.1 - isbinaryfile: 4.0.10 - lodash: 4.17.21 - log4js: 6.9.1 - mime: 2.6.0 - minimatch: 3.1.2 - mkdirp: 0.5.6 - qjobs: 1.2.0 - range-parser: 1.2.1 - rimraf: 3.0.2 - socket.io: 4.7.4 - source-map: 0.6.1 - tmp: 0.2.3 - ua-parser-js: 0.7.37 - yargs: 16.2.0 - transitivePeerDependencies: - - bufferutil - - debug - - supports-color - - utf-8-validate - dev: false - /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: @@ -5024,8 +4691,8 @@ packages: dev: false optional: true - /lightningcss-darwin-arm64@1.24.0: - resolution: {integrity: sha512-rTNPkEiynOu4CfGdd5ZfVOQe2gd2idfQd4EfX1l2ZUUwd+2SwSdbb7cG4rlwfnZckbzCAygm85xkpekRE5/wFw==} + /lightningcss-darwin-arm64@1.24.1: + resolution: {integrity: sha512-1jQ12jBy+AE/73uGQWGSafK5GoWgmSiIQOGhSEXiFJSZxzV+OXIx+a9h2EYHxdJfX864M+2TAxWPWb0Vv+8y4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] @@ -5042,8 +4709,8 @@ packages: dev: false optional: true - /lightningcss-darwin-x64@1.24.0: - resolution: {integrity: sha512-4KCeF2RJjzp9xdGY8zIH68CUtptEg8uz8PfkHvsIdrP4t9t5CIgfDBhiB8AmuO75N6SofdmZexDZIKdy9vA7Ww==} + /lightningcss-darwin-x64@1.24.1: + resolution: {integrity: sha512-R4R1d7VVdq2mG4igMU+Di8GPf0b64ZLnYVkubYnGG0Qxq1KaXQtAzcLI43EkpnoWvB/kUg8JKCWH4S13NfiLcQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] @@ -5060,8 +4727,8 @@ packages: dev: false optional: true - /lightningcss-freebsd-x64@1.24.0: - resolution: {integrity: sha512-FJAYlek1wXuVTsncNU0C6YD41q126dXcIUm97KAccMn9C4s/JfLSqGWT2gIzAblavPFkyGG2gIADTWp3uWfN1g==} + /lightningcss-freebsd-x64@1.24.1: + resolution: {integrity: sha512-z6NberUUw5ALES6Ixn2shmjRRrM1cmEn1ZQPiM5IrZ6xHHL5a1lPin9pRv+w6eWfcrEo+qGG6R9XfJrpuY3e4g==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] @@ -5078,8 +4745,8 @@ packages: dev: false optional: true - /lightningcss-linux-arm-gnueabihf@1.24.0: - resolution: {integrity: sha512-N55K6JqzMx7C0hYUu1YmWqhkHwzEJlkQRMA6phY65noO0I1LOAvP4wBIoFWrzRE+O6zL0RmXJ2xppqyTbk3sYw==} + /lightningcss-linux-arm-gnueabihf@1.24.1: + resolution: {integrity: sha512-NLQLnBQW/0sSg74qLNI8F8QKQXkNg4/ukSTa+XhtkO7v3BnK19TS1MfCbDHt+TTdSgNEBv0tubRuapcKho2EWw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] @@ -5096,8 +4763,8 @@ packages: dev: false optional: true - /lightningcss-linux-arm64-gnu@1.24.0: - resolution: {integrity: sha512-MqqUB2TpYtFWeBvvf5KExDdClU3YGLW5bHKs50uKKootcvG9KoS7wYwd5UichS+W3mYLc5yXUPGD1DNWbLiYKw==} + /lightningcss-linux-arm64-gnu@1.24.1: + resolution: {integrity: sha512-AQxWU8c9E9JAjAi4Qw9CvX2tDIPjgzCTrZCSXKELfs4mCwzxRkHh2RCxX8sFK19RyJoJAjA/Kw8+LMNRHS5qEg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] @@ -5114,8 +4781,8 @@ packages: dev: false optional: true - /lightningcss-linux-arm64-musl@1.24.0: - resolution: {integrity: sha512-5wn4d9tFwa5bS1ao9mLexYVJdh3nn09HNIipsII6ZF7z9ZA5J4dOEhMgKoeCl891axTGTUYd8Kxn+Hn3XUSYRQ==} + /lightningcss-linux-arm64-musl@1.24.1: + resolution: {integrity: sha512-JCgH/SrNrhqsguUA0uJUM1PvN5+dVuzPIlXcoWDHSv2OU/BWlj2dUYr3XNzEw748SmNZPfl2NjQrAdzaPOn1lA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] @@ -5132,8 +4799,8 @@ packages: dev: false optional: true - /lightningcss-linux-x64-gnu@1.24.0: - resolution: {integrity: sha512-3j5MdTh+LSDF3o6uDwRjRUgw4J+IfDCVtdkUrJvKxL79qBLUujXY7CTe5X3IQDDLKEe/3wu49r8JKgxr0MfjbQ==} + /lightningcss-linux-x64-gnu@1.24.1: + resolution: {integrity: sha512-TYdEsC63bHV0h47aNRGN3RiK7aIeco3/keN4NkoSQ5T8xk09KHuBdySltWAvKLgT8JvR+ayzq8ZHnL1wKWY0rw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] @@ -5150,8 +4817,8 @@ packages: dev: false optional: true - /lightningcss-linux-x64-musl@1.24.0: - resolution: {integrity: sha512-HI+rNnvaLz0o36z6Ki0gyG5igVGrJmzczxA5fznr6eFTj3cHORoR/j2q8ivMzNFR4UKJDkTWUH5LMhacwOHWBA==} + /lightningcss-linux-x64-musl@1.24.1: + resolution: {integrity: sha512-HLfzVik3RToot6pQ2Rgc3JhfZkGi01hFetHt40HrUMoeKitLoqUUT5owM6yTZPTytTUW9ukLBJ1pc3XNMSvlLw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] @@ -5168,8 +4835,8 @@ packages: dev: false optional: true - /lightningcss-win32-x64-msvc@1.24.0: - resolution: {integrity: sha512-oeije/t7OZ5N9vSs6amyW/34wIYoBCpE6HUlsSKcP2SR1CVgx9oKEM00GtQmtqNnYiMIfsSm7+ppMb4NLtD5vg==} + /lightningcss-win32-x64-msvc@1.24.1: + resolution: {integrity: sha512-joEupPjYJ7PjZtDsS5lzALtlAudAbgIBMGJPNeFe5HfdmJXFd13ECmEM+5rXNxYVMRHua2w8132R6ab5Z6K9Ow==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -5194,21 +4861,21 @@ packages: lightningcss-win32-x64-msvc: 1.21.8 dev: false - /lightningcss@1.24.0: - resolution: {integrity: sha512-y36QEEDVx4IM7/yIZNsZJMRREIu26WzTsauIysf5s76YeCmlSbRZS7aC97IGPuoFRnyZ5Wx43OBsQBFB5Ne7ng==} + /lightningcss@1.24.1: + resolution: {integrity: sha512-kUpHOLiH5GB0ERSv4pxqlL0RYKnOXtgGtVe7shDGfhS0AZ4D1ouKFYAcLcZhql8aMspDNzaUCumGHZ78tb2fTg==} engines: {node: '>= 12.0.0'} dependencies: detect-libc: 1.0.3 optionalDependencies: - lightningcss-darwin-arm64: 1.24.0 - lightningcss-darwin-x64: 1.24.0 - lightningcss-freebsd-x64: 1.24.0 - lightningcss-linux-arm-gnueabihf: 1.24.0 - lightningcss-linux-arm64-gnu: 1.24.0 - lightningcss-linux-arm64-musl: 1.24.0 - lightningcss-linux-x64-gnu: 1.24.0 - lightningcss-linux-x64-musl: 1.24.0 - lightningcss-win32-x64-msvc: 1.24.0 + lightningcss-darwin-arm64: 1.24.1 + lightningcss-darwin-x64: 1.24.1 + lightningcss-freebsd-x64: 1.24.1 + lightningcss-linux-arm-gnueabihf: 1.24.1 + lightningcss-linux-arm64-gnu: 1.24.1 + lightningcss-linux-arm64-musl: 1.24.1 + lightningcss-linux-x64-gnu: 1.24.1 + lightningcss-linux-x64-musl: 1.24.1 + lightningcss-win32-x64-msvc: 1.24.1 dev: false /lilconfig@2.1.0: @@ -5287,19 +4954,6 @@ packages: is-unicode-supported: 0.1.0 dev: false - /log4js@6.9.1: - resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} - engines: {node: '>=8.0'} - dependencies: - date-format: 4.0.14 - debug: 4.3.4 - flatted: 3.3.1 - rfdc: 1.3.1 - streamroller: 3.1.5 - transitivePeerDependencies: - - supports-color - dev: false - /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -5360,11 +5014,6 @@ packages: resolution: {integrity: sha512-59peAYFlL9ZlFVkKJmIgIDNMkQr4nauYTwIQhLg3khmGfO6/25VNEI8Yn0aUMLb5IFB2gzjcPmfu1ktfOhQ8Ag==} dev: false - /media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - dev: false - /merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} dev: false @@ -5382,18 +5031,6 @@ packages: picomatch: 2.3.1 dev: false - /mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - dev: false - - /mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - dependencies: - mime-db: 1.52.0 - dev: false - /mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -5423,12 +5060,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dev: false - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - dependencies: - brace-expansion: 1.1.11 - dev: false - /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -5447,13 +5078,6 @@ packages: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} dev: false - /mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - dependencies: - minimist: 1.2.8 - dev: false - /mnemonic-id@3.2.7: resolution: {integrity: sha512-kysx9gAGbvrzuFYxKkcRjnsg/NK61ovJOV4F1cHTRl9T5leg+bo6WI0pWIvOFh1Z/yDL0cjA5R3EEGPPLDv/XA==} dev: false @@ -5465,10 +5089,6 @@ packages: webworkify-webpack: 2.1.5 dev: false - /ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - dev: false - /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -5543,16 +5163,11 @@ packages: dev: false optional: true - /negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - dev: false - /node-abi@3.56.0: resolution: {integrity: sha512-fZjdhDOeRcaS+rcpve7XuwHBmktS1nS1gzgghwKUQQ8nTy2FdSDr6ZT8k6YhvlJeHmmQMYiT/IH9hfco5zeW2Q==} engines: {node: '>=10'} dependencies: - semver: 7.5.4 + semver: 7.6.0 dev: false /node-addon-api@4.3.0: @@ -5603,8 +5218,8 @@ packages: engines: {node: '>=0.10.0'} dev: false - /normalize-url@8.0.0: - resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==} + /normalize-url@8.0.1: + resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} engines: {node: '>=14.16'} dev: false @@ -5635,24 +5250,6 @@ packages: engines: {node: '>= 6'} dev: false - /object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - dev: false - - /on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} - dependencies: - ee-first: 1.1.1 - dev: false - - /on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - dependencies: - ee-first: 1.1.1 - dev: false - /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -5702,7 +5299,7 @@ packages: got: 12.6.1 registry-auth-token: 5.0.2 registry-url: 6.0.1 - semver: 7.5.4 + semver: 7.6.0 dev: false /parent-module@1.0.1: @@ -5716,7 +5313,7 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.24.2 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -5727,16 +5324,6 @@ packages: engines: {node: '>= 0.10'} dev: false - /parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - dev: false - - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - dev: false - /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5799,8 +5386,8 @@ packages: engines: {node: '>= 6'} dev: false - /plasmo@0.85.0(postcss@8.4.35)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-sBhMWAlhgqYyHrvXm/3MzTMUONndNoTj46y0sWaYDMDKcawr7KBf6Qpi1HxsCW2yYcgQjbyFrmUAo1vLqwOn8g==} + /plasmo@0.85.2(postcss@8.4.38)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qf9mONnb0JV0quYMM3qi23wuAIabzlhsDmHw4HMuBXjDp0ysruzGoOiFQmAbEMK7lMMFFepPkcDftu0c8MNhBw==} hasBin: true dependencies: '@expo/spawn-async': 1.7.2 @@ -5809,7 +5396,7 @@ packages: '@parcel/package-manager': 2.9.3(@parcel/core@2.9.3) '@parcel/watcher': 2.2.0 '@plasmohq/init': 0.7.0 - '@plasmohq/parcel-config': 0.40.3(postcss@8.4.35)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2) + '@plasmohq/parcel-config': 0.40.5(postcss@8.4.38)(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2) '@plasmohq/parcel-core': 0.1.8 buffer: 6.0.3 chalk: 5.3.0 @@ -5923,29 +5510,29 @@ packages: deprecated: You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1 dev: false - /postcss-import@15.1.0(postcss@8.4.35): + /postcss-import@15.1.0(postcss@8.4.38): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.35 + postcss: 8.4.38 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 dev: false - /postcss-js@4.0.1(postcss@8.4.35): + /postcss-js@4.0.1(postcss@8.4.38): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.35 + postcss: 8.4.38 dev: false - /postcss-load-config@4.0.2(postcss@8.4.35): + /postcss-load-config@4.0.2(postcss@8.4.38): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} peerDependencies: @@ -5958,22 +5545,22 @@ packages: optional: true dependencies: lilconfig: 3.1.1 - postcss: 8.4.35 + postcss: 8.4.38 yaml: 2.4.1 dev: false - /postcss-nested@6.0.1(postcss@8.4.35): + /postcss-nested@6.0.1(postcss@8.4.38): resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.35 - postcss-selector-parser: 6.0.15 + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 dev: false - /postcss-selector-parser@6.0.15: - resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==} + /postcss-selector-parser@6.0.16: + resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==} engines: {node: '>=4'} dependencies: cssesc: 3.0.0 @@ -5984,13 +5571,13 @@ packages: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} dev: false - /postcss@8.4.35: - resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} engines: {node: ^10 || ^12 || >=14} dependencies: nanoid: 3.3.7 picocolors: 1.0.0 - source-map-js: 1.0.2 + source-map-js: 1.2.0 /posthtml-parser@0.10.2: resolution: {integrity: sha512-PId6zZ/2lyJi9LiKfe+i2xv57oEjJgWbsHGGANwos5AvdQp98i6AtamAl8gzSVFGfQ43Glb5D614cvZf012VKg==} @@ -6026,7 +5613,7 @@ packages: engines: {node: '>=10'} hasBin: true dependencies: - detect-libc: 2.0.2 + detect-libc: 2.0.3 expand-template: 2.0.3 github-from-package: 0.0.0 minimist: 1.2.8 @@ -6081,18 +5668,6 @@ packages: engines: {node: '>=6'} dev: false - /qjobs@1.2.0: - resolution: {integrity: sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==} - engines: {node: '>=0.9'} - dev: false - - /qs@6.11.0: - resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} - engines: {node: '>=0.6'} - dependencies: - side-channel: 1.0.6 - dev: false - /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: false @@ -6113,21 +5688,6 @@ packages: performance-now: 2.1.0 dev: false - /range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - dev: false - - /raw-body@2.5.2: - resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} - engines: {node: '>= 0.8'} - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - dev: false - /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -6215,8 +5775,8 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false - /react-joyride@2.7.4(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-7MPuqM/z3g9iqCJZnmKNM2RArNgqYBpM8iknny4KjrHp/2wXlPtFL0LpqGiBhtiC0dCC2xe3pNpD9GdLZ2NxMA==} + /react-joyride@2.8.0(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-h/z3QS6dk+KKw1RuVnDJKFRuoHy/L4eyqZSGZ4S9vIn+/c4lec3svhGQBUhcjvBjl/fFPkN9Uag+l6PPKkj66A==} peerDependencies: react: 15 - 18 react-dom: 15 - 18 @@ -6234,7 +5794,7 @@ packages: scroll: 3.0.1 scrollparent: 2.1.0 tree-changes: 0.11.2 - type-fest: 4.12.0 + type-fest: 4.13.1 transitivePeerDependencies: - '@types/react' dev: false @@ -6336,15 +5896,6 @@ packages: raf: 3.4.1 dev: false - /require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - dev: false - - /requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - dev: false - /resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} dev: false @@ -6388,17 +5939,6 @@ packages: engines: {iojs: '>=1.0.0', node: '>=0.10.0'} dev: false - /rfdc@1.3.1: - resolution: {integrity: sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==} - dev: false - - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - hasBin: true - dependencies: - glob: 7.2.3 - dev: false - /rollup@3.29.4: resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} engines: {node: '>=14.18.0', npm: '>=8.0.0'} @@ -6437,14 +5977,14 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false - /sass@1.71.1: - resolution: {integrity: sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==} + /sass@1.72.0: + resolution: {integrity: sha512-Gpczt3WA56Ly0Mn8Sl21Vj94s1axi9hDIzDFn9Ph9x3C3p4nNyvsqJoQyVXKou6cBlfFWEgRW4rT8Tb4i3XnVA==} engines: {node: '>=14.0.0'} hasBin: true dependencies: chokidar: 3.6.0 immutable: 4.3.5 - source-map-js: 1.0.2 + source-map-js: 1.2.0 dev: false /sax@1.3.0: @@ -6490,10 +6030,9 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true - /set-function-length@1.2.1: - resolution: {integrity: sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==} + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} dependencies: define-data-property: 1.1.4 @@ -6504,20 +6043,16 @@ packages: has-property-descriptors: 1.0.2 dev: false - /setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - dev: false - /sharp@0.32.6: resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} engines: {node: '>=14.15.0'} requiresBuild: true dependencies: color: 4.2.3 - detect-libc: 2.0.2 + detect-libc: 2.0.3 node-addon-api: 6.1.0 prebuild-install: 7.1.2 - semver: 7.5.4 + semver: 7.6.0 simple-get: 4.0.1 tar-fs: 3.0.5 tunnel-agent: 0.6.0 @@ -6533,16 +6068,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - /side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - object-inspect: 1.13.1 - dev: false - /signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: false @@ -6574,46 +6099,8 @@ packages: engines: {node: '>=8'} dev: false - /socket.io-adapter@2.5.4: - resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} - dependencies: - debug: 4.3.4 - ws: 8.11.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - - /socket.io-parser@4.2.4: - resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} - engines: {node: '>=10.0.0'} - dependencies: - '@socket.io/component-emitter': 3.1.0 - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: false - - /socket.io@4.7.4: - resolution: {integrity: sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==} - engines: {node: '>=10.2.0'} - dependencies: - accepts: 1.3.8 - base64id: 2.0.0 - cors: 2.8.5 - debug: 4.3.4 - engine.io: 6.5.4 - socket.io-adapter: 2.5.4 - socket.io-parser: 4.2.4 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: false - - /sonner@1.4.3(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-SArYlHbkjqRuLiR0iGY2ZSr09oOrxw081ZZkQPfXrs8aZQLIBOLOdzTYxGJB5yIZ7qL56UEPmrX1YqbODwG0Lw==} + /sonner@1.4.41(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-uG511ggnnsw6gcn/X+YKkWPo5ep9il9wYi3QJxHsYe7yTZ4+cOd1wuodOUmOpFuXL+/RE3R04LczdNCDygTDgQ==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -6622,8 +6109,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} /source-map@0.5.7: @@ -6653,27 +6140,6 @@ packages: deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' dev: false - /statuses@1.5.0: - resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} - engines: {node: '>= 0.6'} - dev: false - - /statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - dev: false - - /streamroller@3.1.5: - resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} - engines: {node: '>=8.0'} - dependencies: - date-format: 4.0.14 - debug: 4.3.4 - fs-extra: 8.1.0 - transitivePeerDependencies: - - supports-color - dev: false - /streamx@2.16.1: resolution: {integrity: sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==} dependencies: @@ -6834,12 +6300,12 @@ packages: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.0 - postcss: 8.4.35 - postcss-import: 15.1.0(postcss@8.4.35) - postcss-js: 4.0.1(postcss@8.4.35) - postcss-load-config: 4.0.2(postcss@8.4.35) - postcss-nested: 6.0.1(postcss@8.4.35) - postcss-selector-parser: 6.0.15 + postcss: 8.4.38 + postcss-import: 15.1.0(postcss@8.4.38) + postcss-js: 4.0.1(postcss@8.4.38) + postcss-load-config: 4.0.2(postcss@8.4.38) + postcss-nested: 6.0.1(postcss@8.4.38) + postcss-selector-parser: 6.0.16 resolve: 1.22.8 sucrase: 3.35.0 transitivePeerDependencies: @@ -6861,7 +6327,7 @@ packages: pump: 3.0.0 tar-stream: 3.1.7 optionalDependencies: - bare-fs: 2.2.1 + bare-fs: 2.2.2 bare-path: 2.1.0 dev: false @@ -6923,11 +6389,6 @@ packages: os-tmpdir: 1.0.2 dev: false - /tmp@0.2.3: - resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} - engines: {node: '>=14.14'} - dev: false - /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -6939,11 +6400,6 @@ packages: is-number: 7.0.0 dev: false - /toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - dev: false - /tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} dependencies: @@ -6985,7 +6441,7 @@ packages: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: false - /tsup@7.2.0(postcss@8.4.35)(typescript@5.2.2): + /tsup@7.2.0(postcss@8.4.38)(typescript@5.2.2): resolution: {integrity: sha512-vDHlczXbgUvY3rWvqFEbSqmC1L7woozbzngMqTtL2PGBODTtWlRwGDDawhvWzr5c1QjKe4OAKqJGfE1xeXUvtQ==} engines: {node: '>=16.14'} hasBin: true @@ -7009,8 +6465,8 @@ packages: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss: 8.4.35 - postcss-load-config: 4.0.2(postcss@8.4.35) + postcss: 8.4.38 + postcss-load-config: 4.0.2(postcss@8.4.38) resolve-from: 5.0.0 rollup: 3.29.4 source-map: 0.8.0-beta.0 @@ -7048,37 +6504,26 @@ packages: engines: {node: '>=12.20'} dev: false - /type-fest@4.12.0: - resolution: {integrity: sha512-5Y2/pp2wtJk8o08G0CMkuFPCO354FGwk/vbidxrdhRGZfd0tFnb4Qb8anp9XxXriwBgVPjdWbKpGl4J9lJY2jQ==} + /type-fest@4.13.1: + resolution: {integrity: sha512-ASMgM+Vf2cLwDMt1KXSkMUDSYCxtckDJs8zsaVF/mYteIsiARKCVtyXtcK38mIKbLTctZP8v6GMqdNaeI3fo7g==} engines: {node: '>=16'} dev: false - /type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - dev: false - /typescript@5.2.2: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true dev: false - /typescript@5.4.2: - resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + /typescript@5.4.3: + resolution: {integrity: sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==} engines: {node: '>=14.17'} hasBin: true dev: true - /ua-parser-js@0.7.37: - resolution: {integrity: sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==} - dev: false - /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true /unique-string@3.0.0: resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} @@ -7087,21 +6532,11 @@ packages: crypto-random-string: 4.0.0 dev: false - /universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - dev: false - /universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} dev: false - /unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - dev: false - /update-browserslist-db@1.0.13(browserslist@4.22.1): resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} hasBin: true @@ -7132,27 +6567,12 @@ packages: engines: {node: '>= 4'} dev: false - /utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - dev: false - - /vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - dev: false - /virtual-scroller@1.12.4: resolution: {integrity: sha512-y9U5L4aGMDPOQzYVQcq+In8aCSed87tdM6aIHl5WeWNTzjjdlj6fS2kagLvlLwiOJQPzNYxS5VApDj47qpvJuw==} dependencies: request-animation-frame-timeout: 1.0.0 dev: false - /void-elements@2.0.1: - resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==} - engines: {node: '>=0.10.0'} - dev: false - /vue@3.3.4: resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==} dependencies: @@ -7225,28 +6645,10 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: false - /ws@8.11.0: - resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: false - /xxhash-wasm@0.4.2: resolution: {integrity: sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==} dev: false - /y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - dev: false - /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -7263,21 +6665,3 @@ packages: engines: {node: '>= 14'} hasBin: true dev: false - - /yargs-parser@20.2.9: - resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} - engines: {node: '>=10'} - dev: false - - /yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} - engines: {node: '>=10'} - dependencies: - cliui: 7.0.4 - escalade: 3.1.2 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 20.2.9 - dev: false diff --git a/src/api/github.ts b/src/api/github.ts new file mode 100644 index 00000000..3818bfc2 --- /dev/null +++ b/src/api/github.ts @@ -0,0 +1,10 @@ +import type { ReleaseInfo } from "~types/github"; +import { fetchAndCache } from "~utils/fetch"; + +export async function getLatestRelease(): Promise { + return await fetchAndCache('https://api.github.com/repos/eric2788/bilibili-vup-stream-enhancer/releases/latest') +} + +export async function getRelease(tag: string): Promise { + return await fetchAndCache(`https://api.github.com/repos/eric2788/bilibili-vup-stream-enhancer/releases/tags/${tag}`) +} \ No newline at end of file diff --git a/src/background/messages.ts b/src/background/messages.ts index 590903f8..e3340645 100644 --- a/src/background/messages.ts +++ b/src/background/messages.ts @@ -1,5 +1,4 @@ import * as addBlackList from './messages/add-black-list' -import * as checkUpdate from './messages/check-update' import * as clearTable from './messages/clear-table' import * as fetchDeveloper from './messages/fetch-developer' import * as getStreamUrls from './messages/get-stream-urls' @@ -63,7 +62,6 @@ const messagers = { 'open-tab': openTab, 'request': request, 'get-stream-urls': getStreamUrls, - 'check-update': checkUpdate, 'fetch-developer': fetchDeveloper, 'open-window': openWindow, 'clear-table': clearTable, diff --git a/src/background/messages/check-update.ts b/src/background/messages/check-update.ts deleted file mode 100644 index 11240e1e..00000000 --- a/src/background/messages/check-update.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { PlasmoMessaging } from "@plasmohq/messaging" -import { sendInternal } from '~background/messages' -import updaters from '~updaters' - -export const browser = process.env.PLASMO_BROWSER || 'chrome' - -export async function notifyUpdate(version: string): Promise { - await sendInternal('notify', { - title: 'bilibili-vup-stream-enhancer 有可用的更新', - message: `新版本 v${version || 'SNAPSHOT'}`, - buttons: [ - { - title: '下載更新', - clicked: update - }, - { - title: '查看更新日誌', - clicked: () => { - chrome.tabs.create({ - url: `https://github.com/eric2788/bilibili-vup-stream-enhancer/releases/tag/${version}` - }) - } - } - ] - }) -} - -export async function checkUpdate(): Promise { - const { version } = chrome.runtime.getManifest() - const { checkUpdate } = updaters[browser] ?? updaters['chrome'] - return await checkUpdate(version) -} - - -export async function update(): Promise { - try { - const { update } = updaters[browser] ?? updaters['chrome'] - await update() - } catch (err: Error | any) { - console.error(err) - await sendInternal('notify', { - title: '更新失敗', - message: err.message ?? err - }) - } -} - -export type RequestBody = {} - -const handler: PlasmoMessaging.MessageHandler = async (req, res) => { - const { status, version } = await checkUpdate() - if (status === 'update_available') { - await notifyUpdate(version) - } else if (status === 'no_update') { - await sendInternal('notify', { - title: 'bilibili-vup-stream-enhancer 已是最新版本', - message: `當前版本 v${version || 'SNAPSHOT'}` - }) - } else { - await sendInternal('notify', { - title: '檢查更新失敗', - message: `无法索取版本消息,请稍后再尝试。` - }) - } - res.send({ status, version }) -} - - -export default handler \ No newline at end of file diff --git a/src/background/messages/notify.ts b/src/background/messages/notify.ts index 26036bc6..04a0faa8 100644 --- a/src/background/messages/notify.ts +++ b/src/background/messages/notify.ts @@ -2,9 +2,8 @@ import type { PlasmoMessaging } from "@plasmohq/messaging" import icon from 'raw:assets/icon.png' -export type RequestBody = { - title: string, - message: string +export type RequestBody = Partial, 'buttons'>> & { + messages?: string[], buttons?: (chrome.notifications.ButtonOptions & { clicked: (id: string, index: number) => void })[], onClicked?: (id: string) => void } @@ -22,14 +21,15 @@ async function createNotification(option: chrome.notifications.NotificationOptio } const handler: PlasmoMessaging.MessageHandler = async (req, res) => { - const { title, message, buttons, onClicked } = req.body + const { title, message, messages, type, buttons, onClicked, ...option } = req.body const id = await createNotification({ - type: 'basic', + type: type ?? 'basic', title, - message, + message: message ?? messages?.join('\n') ?? '', + buttons: buttons?.map(({ clicked, ...option }) => option), + ...option, iconUrl: icon }) - const callbackFunc = (notificationId: string) => { if (id !== notificationId) return onClicked(notificationId) diff --git a/src/background/update-listener.ts b/src/background/update-listener.ts index c3588d4c..985d02f5 100644 --- a/src/background/update-listener.ts +++ b/src/background/update-listener.ts @@ -1,10 +1,10 @@ -import storage, { localStorage } from '~utils/storage' +import storage, { getSettingStorage, localStorage } from '~utils/storage' import { sendInternal } from './messages' -import { notifyUpdate } from './messages/check-update' import { type MV2Settings } from '~migrations/schema' import semver from 'semver' import migrateFromMV2 from '~migrations' +import { getLatestRelease } from '~api/github' chrome.runtime.onInstalled.addListener(async (data: chrome.runtime.InstalledDetails) => { @@ -63,6 +63,26 @@ chrome.runtime.onInstalled.addListener(async (data: chrome.runtime.InstalledDeta }) -chrome.runtime.onUpdateAvailable.addListener((data: chrome.runtime.UpdateAvailableDetails) => notifyUpdate(data.version)) - +getSettingStorage('settings.version').then(async (settings) => { + if (!settings.autoCheckUpdate) return + const currentVersion = chrome.runtime.getManifest().version + const latest = await getLatestRelease() + if (semver.lt(currentVersion, latest.tag_name)) { + await sendInternal('notify', { + type: 'list', + title: 'bilibili-vup-stream-enhancer 已推出新版本', + items: [ + { title: '当前版本', message: `v${currentVersion}` }, + { title: '最新版本', message: `v${latest.tag_name}` }, + { title: '发布日期', message: new Date(latest.published_at).toDateString() } + ], + buttons: [ + { + title: '查看更新日志', + clicked: () => sendInternal('open-tab', { url: latest.html_url }) + } + ] + }) + } +}) diff --git a/src/settings/fragments/version.tsx b/src/settings/fragments/version.tsx new file mode 100644 index 00000000..c73bf643 --- /dev/null +++ b/src/settings/fragments/version.tsx @@ -0,0 +1,106 @@ +import { Button, IconButton, List, Typography } from "@material-tailwind/react" +import { type ChangeEvent, Fragment, useState } from "react" +import { getLatestRelease, getRelease } from "~api/github" +import PromiseHandler from "~components/PromiseHandler" +import type { StateProxy } from "~hooks/binding" +import SwitchListItem from "~settings/components/SwitchListItem" +import type { ReleaseInfo } from "~types/github" +import semver from 'semver'; + + +export type SettingSchema = { + autoCheckUpdate: boolean, +} + +export const defaultSettings: Readonly = { + autoCheckUpdate: false, +} + +export const title = '版本资讯' + +export const description = `此设定区块包含了一些版本相关的设定, 你可以在这里调整各个版本相关的设定。` + + +async function getTwoReleaseInfo(): Promise { + const currentVersion = chrome.runtime.getManifest().version + const latest = getLatestRelease() + const current = getRelease(currentVersion) + return Promise.all([current, latest]) +} + + +function VersionSettings({ state, useHandler }: StateProxy): JSX.Element { + + const bool = useHandler, boolean>(e => e.target.checked) + + const [fetcher, setFetcher] = useState(() => getTwoReleaseInfo) + + const refresh = () => setFetcher(() => () => getTwoReleaseInfo()) + + return ( + +
+ + + +
+
+ + +
+ 正在检查更新... +
+ + + +
+
+
+ + {([current, last]: ReleaseInfo[]) => ( +
+
+ {semver.lt(current.tag_name, last.tag_name) ? ( + + 你有可用的更新 + + ) : ( + + 你的版本已是最新 + + )} + 当前版本: v{current.tag_name} + 发布日期: {new Date(current.published_at).toLocaleDateString()} | 更新日志 + Github 最新版本: v{last.tag_name} + 发布日期: {new Date(last.published_at).toLocaleDateString()} | 更新日志 +
+
+ +
+
+ )} +
+ + {err => ( +
+
+ 检查更新失败: {err?.message ?? err} +
+
+ +
+
+ )} +
+
+
+
+ ) +} + +export default VersionSettings diff --git a/src/settings/index.ts b/src/settings/index.ts index 42c06f9e..8611c43b 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -4,6 +4,7 @@ import * as developer from './fragments/developer' import * as display from './fragments/display' import * as features from './fragments/features' import * as listings from './fragments/listings' +import * as version from './fragments/version' import type { StreamInfo } from '~api/bilibili' @@ -43,7 +44,8 @@ const fragments = { 'settings.listings': listings, 'settings.capture': capture, 'settings.display': display, - 'settings.developer': developer + 'settings.developer': developer, + 'settings.version': version } diff --git a/src/tabs/settings.tsx b/src/tabs/settings.tsx index bc395d91..799366f4 100644 --- a/src/tabs/settings.tsx +++ b/src/tabs/settings.tsx @@ -1,6 +1,6 @@ import '~style.css'; -import { Fragment, useContext, useRef, type Ref, type RefObject } from 'react'; +import { Fragment, useContext, useRef, type RefObject } from 'react'; import BJFThemeProvider from '~components/BJFThemeProvider'; import { useBinding } from '~hooks/binding'; import { useForwarder } from '~hooks/forwarder'; @@ -84,17 +84,6 @@ async function clearRecords(): Promise { await clearing } -async function checkingUpdate(): Promise { - try { - await sendMessager('check-update') - } catch (err: Error | any) { - await sendMessager('notify', { - title: '检查更新失败', - message: err.message - }) - } -} - function SettingPage(): JSX.Element { const tutorial = useContext>(GenericContext) @@ -109,107 +98,106 @@ function SettingPage(): JSX.Element { const processing = useStorageWatch('processing', 'session', false) const forwarder = useForwarder('command', 'pages') - const [loader, loading] = useLoader({ - checkingUpdate, - exportSettings, - clearRecords, - migrateSettings: async () => { - if (!window.confirm('这将覆盖所有受影响的原有设定,确定继续?')) return - const migrating = (async () => { - const { data: settings, error } = await sendMessager('migration-mv2') - if (error) throw new Error(error) - if (!settings) throw new Error('找不到舊設定,無法遷移') - // do import - await Promise.all(fragmentRefs.map((ref) => { - const fragmentKey = ref.current.fragmentKey - const { defaultSettings } = fragments[fragmentKey] - const importContent = removeInvalidKeys({ ...defaultSettings, ...settings[fragmentKey] }, defaultSettings as Schema) - return ref.current.importSettings(importContent) - })) - })(); - toast.promise(migrating, { - loading: '正在迁移设定...', - success: '设定已迁移并导入成功。', - error: err => '迁移设定失败: ' + err.message, - action: { - label: '删除旧设定', - onClick: removeAllMV2Settings - } - }) - await migrating - if (!processing) { - // 向所有页面发送重启指令 - forwarder.sendForward('content-script', { command: 'restart' }) + const migrateSettings = async () => { + if (!window.confirm('这将覆盖所有受影响的原有设定,确定继续?')) return + const migrating = (async () => { + const { data: settings, error } = await sendMessager('migration-mv2') + if (error) throw new Error(error) + if (!settings) throw new Error('找不到舊設定,無法遷移') + // do import + await Promise.all(fragmentRefs.map((ref) => { + const fragmentKey = ref.current.fragmentKey + const { defaultSettings } = fragments[fragmentKey] + const importContent = removeInvalidKeys({ ...defaultSettings, ...settings[fragmentKey] }, defaultSettings as Schema) + return ref.current.importSettings(importContent) + })) + })(); + toast.promise(migrating, { + loading: '正在迁移设定...', + success: '设定已迁移并导入成功。', + error: err => '迁移设定失败: ' + err.message, + action: { + label: '删除旧设定', + onClick: removeAllMV2Settings } - }, - importSettings: async () => { - const listener = async (e: Event) => { - const target = e.target as HTMLInputElement - if (target.files.length === 0) return - const file = target.files[0] - try { - const importing = (async () => { - const settings = (await readAsJson(file)) as Settings - if (!(settings instanceof Object)) { - throw new Error('导入的设定文件格式错误。') - } - if (!arrayEqual(Object.keys(settings), fragmentKeys)) { - throw new Error('导入的设定文件格式错误。') - } - await Promise.all(fragmentRefs.map((ref) => { - const fragmentKey = ref.current.fragmentKey - const { defaultSettings } = fragments[fragmentKey] - const importContent = removeInvalidKeys({ ...defaultSettings, ...settings[fragmentKey] }, defaultSettings as Schema) - return ref.current.importSettings(importContent) - })) - })(); - toast.promise(importing, { - loading: '正在导入设定...', - success: '设定已经导入成功。', - error: err => '导入设定失败: ' + err.message - }) - await importing - if (!processing) { - // 向所有页面发送重启指令 - forwarder.sendForward('content-script', { command: 'restart' }) + }) + await migrating + if (!processing) { + // 向所有页面发送重启指令 + forwarder.sendForward('content-script', { command: 'restart' }) + } + } + + const importSettings = async () => { + const listener = async (e: Event) => { + const target = e.target as HTMLInputElement + if (target.files.length === 0) return + const file = target.files[0] + try { + const importing = (async () => { + const settings = (await readAsJson(file)) as Settings + if (!(settings instanceof Object)) { + throw new Error('导入的设定文件格式错误。') } - } catch (err: Error | any) { - console.error(err) - toast.error('导入设定失败: ', { - description: err.message - }) - } finally { - fileImport.current.files = null - fileImport.current.removeEventListener('change', listener) + if (!arrayEqual(Object.keys(settings), fragmentKeys)) { + throw new Error('导入的设定文件格式错误。') + } + await Promise.all(fragmentRefs.map((ref) => { + const fragmentKey = ref.current.fragmentKey + const { defaultSettings } = fragments[fragmentKey] + const importContent = removeInvalidKeys({ ...defaultSettings, ...settings[fragmentKey] }, defaultSettings as Schema) + return ref.current.importSettings(importContent) + })) + })(); + toast.promise(importing, { + loading: '正在导入设定...', + success: '设定已经导入成功。', + error: err => '导入设定失败: ' + err.message + }) + await importing + if (!processing) { + // 向所有页面发送重启指令 + forwarder.sendForward('content-script', { command: 'restart' }) } - } - fileImport.current.addEventListener('change', listener) - fileImport.current.click() - }, - saveAllSettings: async () => { - if (!form.current.checkValidity()) { - form.current.reportValidity() - return - } - if (fragmentRefs.every(ref => ref.current.saveSettings === undefined)) { - toast.warning('无需保存设定', { - description: '没有设定被变更。' + } catch (err: Error | any) { + console.error(err) + toast.error('导入设定失败: ', { + description: err.message }) - return + } finally { + fileImport.current.files = null + fileImport.current.removeEventListener('change', listener) } - const saving = Promise.all(fragmentRefs.map(ref => ref.current.saveSettings())) - toast.promise(saving, { - loading: '正在保存设定...', - success: '所有设定已经保存成功。', - error: err => '保存设定失败: ' + err.message + } + fileImport.current.addEventListener('change', listener) + fileImport.current.click() + } + + const saveAllSettings = async () => { + if (!form.current.checkValidity()) { + form.current.reportValidity() + return + } + if (fragmentRefs.every(ref => ref.current.saveSettings === undefined)) { + toast.warning('无需保存设定', { + description: '没有设定被变更。' }) - await saving - if (!processing) { - // 向所有页面发送重启指令 - forwarder.sendForward('content-script', { command: 'restart' }, { url: '*://live.bilibili.com/*' }) - } + return } - }) + const saving = Promise.all(fragmentRefs.map(ref => ref.current.saveSettings())) + toast.promise(saving, { + loading: '正在保存设定...', + success: '所有设定已经保存成功。', + error: err => '保存设定失败: ' + err.message + }) + await saving + if (!processing) { + // 向所有页面发送重启指令 + forwarder.sendForward('content-script', { command: 'restart' }, { url: '*://live.bilibili.com/*' }) + } + } + + const [loader, loading] = useLoader({ exportSettings, clearRecords, migrateSettings, importSettings, saveAllSettings, }) return ( @@ -223,12 +211,6 @@ function SettingPage(): JSX.Element {

设定页面

按下储存后可即时生效。

-
)} {info.isTheme && document.querySelector(upperButtonArea) !== null && createPortal( - setShow(!show)} />, + + setShow(!show)} /> + , document.querySelector(upperButtonArea) )}
diff --git a/src/features/jimaku/index.tsx b/src/features/jimaku/index.tsx index 795d3877..7b7f63bf 100644 --- a/src/features/jimaku/index.tsx +++ b/src/features/jimaku/index.tsx @@ -51,7 +51,7 @@ export function App(): JSX.Element { // danmaku style callback useMutationObserver(danmakuArea, (mutationsList: MutationRecord[]) => { - for (const node of mutationsList.flatMap(mu => mu.addedNodes).flatMap(node => [...node.values()])) { + for (const node of mutationsList.flatMap(mu => mu.addedNodes).flatMap(node => [...Array.from(node)])) { let danmaku: string = undefined let danmakuNode: HTMLElement = undefined if (node instanceof Text) { diff --git a/src/features/recorder/components/RecorderButton.tsx b/src/features/recorder/components/RecorderButton.tsx new file mode 100644 index 00000000..a44ecf8a --- /dev/null +++ b/src/features/recorder/components/RecorderButton.tsx @@ -0,0 +1,65 @@ +import { IconButton, Tooltip } from "@material-tailwind/react" +import { useInterval } from "@react-hooks-library/core" +import { useContext, useState, type MutableRefObject } from "react" +import TailwindScope from "~components/TailwindScope" +import RecorderFeatureContext from "~contexts/RecorderFeatureContext" +import { useForceRender } from "~hooks/force-update" +import { useComputedStyle, useContrast } from "~hooks/styles" +import type { Recorder } from "~types/media" +import { toTimer } from "~utils/misc" + +export type RecorderButtonProps = { + recorder: MutableRefObject + onClick?: () => void +} + + +function RecorderButton(props: RecorderButtonProps): JSX.Element { + + const { duration } = useContext(RecorderFeatureContext) + const { recorder, onClick } = props + const [timer, setTimer] = useState(0) + const [recording, setRecording] = useState(false) + const update = useForceRender() + const { backgroundImage } = useComputedStyle(document.getElementById('head-info-vm')) + + useInterval(() => { + if (!recorder.current) return + update() + if (recording !== recorder.current.recording) { + setRecording(recorder.current.recording) + } + + if (recording) { + if (timer === duration * 60) return // if reached duration, stop increasing timer + setTimer(timer + 1) + } else { + setTimer(0) + } + }, 1000) + + if (!recorder.current) return null + + return ( + +
+ + + {recording ? + : + + } + + + {recording && ( +
+ {toTimer(timer)} +
+ )} +
+
+ ) +} + + +export default RecorderButton \ No newline at end of file diff --git a/src/features/recorder/components/RecorderLayer.tsx b/src/features/recorder/components/RecorderLayer.tsx new file mode 100644 index 00000000..176e7504 --- /dev/null +++ b/src/features/recorder/components/RecorderLayer.tsx @@ -0,0 +1,219 @@ +import type { ProgressEvent } from "@ffmpeg/ffmpeg/dist/esm/types" +import { Progress, Spinner } from "@material-tailwind/react" +import { useKeyDown } from "@react-hooks-library/core" +import { useCallback, useContext, useRef, useState } from "react" +import { createPortal } from "react-dom" +import { toast } from "sonner/dist" +import type { StreamUrls } from "~background/messages/get-stream-urls" +import TailwindScope from "~components/TailwindScope" +import ContentContext from "~contexts/ContentContexts" +import RecorderFeatureContext from "~contexts/RecorderFeatureContext" +import { FFMpegHooks, useFFMpeg } from "~hooks/ffmpeg" +import { useAsyncEffect } from "~hooks/life-cycle" +import { useShardSender } from "~hooks/stream" +import { Recorder } from "~types/media" +import { downloadBlob } from "~utils/file" +import { sendMessager } from "~utils/messaging" +import { randomString } from '~utils/misc' +import createRecorder from "../recorders" +import RecorderButton from "./RecorderButton" + +export type RecorderLayerProps = { + urls: StreamUrls +} + + +function ProgressText({ ffmpeg }: { ffmpeg: Promise }) { + + const [progress, setProgress] = useState(null) + + useAsyncEffect( + async () => { + const ff = await ffmpeg + ff.onProgress(setProgress) + }, + async () => { }, + (err) => { + console.error('unexpected: ', err) + }, + [ffmpeg]) + + if (!progress) { + return `编译视频中...` + } + + return ( + +
+
+
+ +
+
+ {`编译视频中... (${Math.round(progress.progress * 10000) / 100}%)`} +
+
+ +
+
+ ) + +} + +function RecorderLayer(props: RecorderLayerProps): JSX.Element { + + const { urls } = props + const { info, settings } = useContext(ContentContext) + const { elements: { upperHeaderArea } } = settings['settings.developer'] + const { + duration, + hotkeyClip, + recordFix, + mechanism, + hiddenUI, + outputType, + overflow + } = useContext(RecorderFeatureContext) + + const recorder = useRef() + const { ffmpeg } = useFFMpeg() + const manual = duration <= 0 + const sendStreamToBackground = useShardSender('content-script') + + useAsyncEffect( + async () => { + recorder.current = createRecorder(info.room, urls, mechanism, { type: outputType, codec: 'avc' }) // ffmpeg.wasm is not supported hevc codec + await recorder.current.flush() // clear old records + if (!manual) { + await recorder.current.start() + } + recorder.current.onerror = (err) => { + console.error('recorder error: ', err) + toast.error('录制直播推流时出现错误: ' + err.message) + } + }, + async () => { + if (recorder.current) { + recorder.current.stop() + console.info('recorder destoryed') + } else { + console.warn('recorder is not ready, skipped clean up') + } + }, + (err) => { + console.error('錄製直播推流失敗: ', err) + toast.error('錄製直播推流失敗: ' + err) + }, + [urls]) + + const clipRecord = useCallback(async () => { + + console.debug('hotkey triggered!') + + if (!recorder.current) { + console.warn('錄製器未初始化') + toast.warning('录制功能尚未初始化') + return + } + + if (!recorder.current.recording) { + if (manual) { + await recorder.current.start() + toast.info('开始录制...') + } else { + toast.warning('录制没有在加载时自动开始,请稍等片刻或刷新页面。') + } + return + } + + const encoding = (async () => { + const chunkData = await recorder.current.loadChunkData(overflow === 'limit') + if (chunkData.chunks.length === 0) { + throw new Error('录制的内容为空。') + } + const original = new Blob([...chunkData.chunks], { type: chunkData.info.mimeType }) + const today = new Date().toString().substring(0, 24).replaceAll(' ', '-').replaceAll(':', '-') + const filename = `${info.room}-${today}.${chunkData.info.extension}` + + // 超出 2GB 时,如果满溢策略是跳过,则直接下载 + if (overflow === 'skip' && recorder.current.fileSizeMB >= 2048) { + downloadBlob(original, filename) + return + } + + const ff = await ffmpeg + + // 如果为完整编译,则发送到后台进行多线程编译 + if (recordFix === 'reencode') { + const id = randomString() + await sendMessager('open-tab', { tab: 'encoder', active: false, params: { id } }) + await sendStreamToBackground('pages', { + id, + duration, + videoInfo: chunkData.info, + filename, + blob: original + }) + } else { + const fixed = await ff.fixInfoAndCut(original, duration, chunkData.info.extension) + downloadBlob(new Blob([fixed], { type: chunkData.info.mimeType }), filename) + } + + })(); + + if (overflow === 'skip' && recorder.current.fileSizeMB >= 2048) { + toast.promise(encoding, { + loading: '准备下载中...', + error: err => `下载失败: ${err?.message ?? err}`, + success: '视频下载成功。' + }) + } else { + toast.promise(encoding, { + loading: recordFix === 'reencode' ? '准备视频中...' : , + error: err => `视频${recordFix === 'reencode' ? '准备' : '编译'}失败: ${err?.message ?? err}`, + success: recordFix === 'reencode' ? '视频已发送到后台进行完整编码。' : '视频下载成功。' + }) + } + + + try { + await encoding + } catch (err: Error | any) { + console.error('unexpected error: ', err, err.stack) + } + + if (manual) { + recorder.current.stop() + await recorder.current.flush() // clear records after download + toast.info('录制已中止。') + } + + }, [ffmpeg]) + + useKeyDown(hotkeyClip.key, async (e) => { + if (e.ctrlKey !== hotkeyClip.ctrlKey) return + if (e.shiftKey !== hotkeyClip.shiftKey) return + try { + await clipRecord() + } catch (err: Error | any) { + console.error('unexpected error: ', err) + toast.error('未知错误: ' + err.message) + } + }) + + if (hiddenUI || document.querySelector(upperHeaderArea) === null) { + return null + } + + return createPortal( + , + document.querySelector(upperHeaderArea) + ) + +} + + +export default RecorderLayer \ No newline at end of file diff --git a/src/features/recorder/index.tsx b/src/features/recorder/index.tsx new file mode 100644 index 00000000..3425bf33 --- /dev/null +++ b/src/features/recorder/index.tsx @@ -0,0 +1,25 @@ +import RecorderFeatureContext from "~contexts/RecorderFeatureContext"; +import type { FeatureHookRender } from "~features"; +import { sendMessager } from "~utils/messaging"; +import RecorderLayer from "./components/RecorderLayer"; +import { toast } from "sonner"; + +export const FeatureContext = RecorderFeatureContext + +const handler: FeatureHookRender = async (settings, info) => { + + const { error, data: urls } = await sendMessager('get-stream-urls', { roomId: info.room }) + if (error) { + toast.error('启用快速切片功能失败: '+ error) + return undefined // disable the feature + } + + return [ + + ] +} + + + + +export default handler \ No newline at end of file diff --git a/src/features/recorder/recorders/buffer.ts b/src/features/recorder/recorders/buffer.ts new file mode 100644 index 00000000..ce6b7f6e --- /dev/null +++ b/src/features/recorder/recorders/buffer.ts @@ -0,0 +1,95 @@ +import db from "~database"; +import type { Streams } from "~database/tables/stream"; +import { recordStream } from "~players"; +import type { StreamPlayer } from "~types/media"; +import { Recorder } from "~types/media"; +import { type ChunkData } from "."; + +class BufferRecorder extends Recorder { + + private player: StreamPlayer = null + private readonly fallbackChunks: Streams[] = [] + private errorHandler: (error: Error) => void = null + private bufferAppendChecker: NodeJS.Timeout = null + + async start(): Promise { + let i = 0 + this.player = await recordStream(this.urls, (buffer) => this.onBufferArrived(++i, buffer), this.options) + let lastRecordedSize = 0 + this.bufferAppendChecker = setInterval(() => { + if (!this.recording) { + clearInterval(this.bufferAppendChecker) + return + } + if (lastRecordedSize !== this.recordedSize) return + console.warn('buffer data has not been appended for 15 seconds! current recorded size: ', this.fileSize) + this.errorHandler?.(new Error('已超过15秒没再接收到数据流!你可能需要刷新页面')) + lastRecordedSize = this.recordedSize + }, 15000) + } + + private async onBufferArrived(order: number, buffer: ArrayBuffer): Promise { + const blob = new Blob([buffer], { type: 'application/octet-stream' }) + const stream = { + date: new Date().toISOString(), + content: blob, + order, + room: this.room + } + try { + await db.streams.add(stream) + console.debug('recorded segment: ', buffer.byteLength, 'bytes, order: ', stream.order) + } catch (err: Error | any) { + console.error('Error writing buffer to file', err) + console.warn('writing into fallback chunks') + this.fallbackChunks.push(stream) + } finally { + this.recordedSize += buffer.byteLength + } + } + + async loadChunkData(flush: boolean = true): Promise { + + const streams = await db.streams.where({ room: this.room }).sortBy('order') + if (flush) { + while (this.recordedSize >= (Recorder.FFmpegLimit - 1024) && streams.length > 0) { // 2GB - 1KB + console.info(`recorded size exceeds 2GB (${this.fileSize}), deleting oldest record`) + const { id, content } = streams.shift() + await db.streams.delete(id) + this.recordedSize -= content.size + } + } + const chunks = [...streams, ...this.fallbackChunks].toSorted((a, b) => a.order - b.order).map(c => c.content) + return { + chunks, + info: this.player.videoInfo + } + } + + async flush(): Promise { + this.recordedSize = 0 + const re = await db.streams.where({ room: this.room }).delete() + this.fallbackChunks.length = 0 + console.debug('flushed ', re, ' records from databases') + } + + stop(): void { + clearInterval(this.bufferAppendChecker) + this.player?.stopAndDestroy() + this.player = null + } + + get recording(): boolean { + return !!this.player + } + + set onerror(handler: (error: Error) => void) { + if (!this.player) return + if (this.errorHandler) this.player.off('error', this.errorHandler) + this.player.on('error', handler) + this.errorHandler = handler + } + +} + +export default BufferRecorder \ No newline at end of file diff --git a/src/features/recorder/recorders/index.ts b/src/features/recorder/recorders/index.ts new file mode 100644 index 00000000..0ba59356 --- /dev/null +++ b/src/features/recorder/recorders/index.ts @@ -0,0 +1,26 @@ +import type { StreamUrls } from "~background/messages/get-stream-urls" +import type { PlayerOptions, PlayerType, VideoInfo } from "~players" +import { Recorder } from "~types/media" +import buffer from "./buffer" + +export type ChunkData = { + chunks: Blob[] + info: VideoInfo +} + +export type RecorderType = keyof typeof recorders + +const recorders = { + buffer +} + + +function createRecorder(room: string, urls: StreamUrls, type: RecorderType, options: PlayerOptions = { codec: 'avc' }): Recorder { + const Recorder = recorders[type] + if (!Recorder) { + throw new Error('unsupported recorder type: ' + type) + } + return new Recorder(room, urls, options) +} + +export default createRecorder \ No newline at end of file diff --git a/src/ffmpeg/core-mt.ts b/src/ffmpeg/core-mt.ts new file mode 100644 index 00000000..882a61ac --- /dev/null +++ b/src/ffmpeg/core-mt.ts @@ -0,0 +1,50 @@ +import type { FFMpegCore } from "~ffmpeg"; + +import classWorkerURL from 'url:assets/ffmpeg/mt-worker.js' + +import workerURL from 'url:@ffmpeg/core-mt/dist/umd/ffmpeg-core.worker.js' +import wasmURL from 'raw:@ffmpeg/core-mt/dist/umd/ffmpeg-core.wasm' +import coreURL from 'url:assets/ffmpeg/mt-core.js' + +import { toBlobURL } from '@ffmpeg/util'; +import type { FFmpeg } from "@ffmpeg/ffmpeg"; +import { getSettingStorage } from "~utils/storage"; + +export class MultiThread implements FFMpegCore { + + private divider = 0.5 // default divider + + async load(ffmpeg: FFmpeg): Promise { + + try { + await this.loadDivide() + } catch (err: Error | any) { + console.warn('无法从设定获取线程占用,使用默认值: 50%', err) + } + + return ffmpeg.load({ + coreURL, + wasmURL, + workerURL, + classWorkerURL: await toBlobURL(classWorkerURL, 'application/javascript'), + }) + } + + get args(): string[] { + return [ + '-threads', `${Math.round(window.navigator.hardwareConcurrency * this.divider)}`, + '-vcodec', 'h264', + ] + } + + + // 从设定加载线程数量 + async loadDivide(): Promise { + const feature = await getSettingStorage('settings.features') + this.divider = feature.recorder.threads + } + +} + +const multiThread = new MultiThread() +export default multiThread \ No newline at end of file diff --git a/src/ffmpeg/core.ts b/src/ffmpeg/core.ts new file mode 100644 index 00000000..7bd6d5be --- /dev/null +++ b/src/ffmpeg/core.ts @@ -0,0 +1,22 @@ +import type { FFMpegCore } from "~ffmpeg"; + +import type { FFmpeg } from "@ffmpeg/ffmpeg"; +import { toBlobURL } from "@ffmpeg/util"; +import ffmpegWorkerJs from 'url:assets/ffmpeg/worker.js'; + +export class SingleThread implements FFMpegCore { + + async load(ffmpeg: FFmpeg): Promise { + return ffmpeg.load({ + classWorkerURL: await toBlobURL(ffmpegWorkerJs, 'text/javascript') + }) + } + + get args(): string[] { + return ['-c', 'copy'] // in single thread mode, we just copy the input file to speed up the process + } + +} + +const singleThread = new SingleThread() +export default singleThread \ No newline at end of file diff --git a/src/ffmpeg/index.ts b/src/ffmpeg/index.ts new file mode 100644 index 00000000..3683f9bc --- /dev/null +++ b/src/ffmpeg/index.ts @@ -0,0 +1,18 @@ +import type { FFmpeg } from "@ffmpeg/ffmpeg" +import { isBackgroundScript } from "~utils/file" +import coreSt from './core' +import coreMt from './core-mt' + +export interface FFMpegCore { + + load(ffmpeg: FFmpeg): Promise + + get args(): string[] + +} + +function getFFMpegCore(): FFMpegCore { + return isBackgroundScript() ? coreMt : coreSt +} + +export default getFFMpegCore \ No newline at end of file diff --git a/src/hooks/ffmpeg.ts b/src/hooks/ffmpeg.ts new file mode 100644 index 00000000..0b6e7cd1 --- /dev/null +++ b/src/hooks/ffmpeg.ts @@ -0,0 +1,152 @@ +import { FFmpeg } from "@ffmpeg/ffmpeg"; +import type { LogEventCallback, ProgressEvent, ProgressEventCallback } from "@ffmpeg/ffmpeg/dist/esm/types"; +import { useMemo, useRef } from "react"; + +import getFFMpegLoader, { type FFMpegCore } from "~ffmpeg"; +import { isBackgroundScript } from "~utils/file"; +import { useAsyncEffect } from "./life-cycle"; +import getFFMpegCore from "~ffmpeg"; +import { downloadWithProgress, fetchFile } from "@ffmpeg/util"; +import { formatBytes } from "~utils/binary"; + + +export type FFMpegProgress = ProgressEvent & { + stage: 'fix' | 'cut' +} + +export type FFMpegProgressCallback = (event: FFMpegProgress) => void + +/** + * Represents a class that provides hooks for interacting with FFmpeg. + */ +export class FFMpegHooks implements Disposable { + + private ffmpeg: FFmpeg + private logEventCallback: LogEventCallback + private progressCallback: ProgressEventCallback + private readonly ffCore: FFMpegCore + + private stage: 'fix' | 'cut' = 'fix' + + constructor() { + this.ffCore = getFFMpegCore() + this.ffmpeg = new FFmpeg() + this.logEventCallback = ({ type, message }) => console.log(`[${type}] ${message}`) + } + + /** + * Loads FFmpeg and initializes the necessary callbacks. + * @throws Error if FFmpeg is already loaded. + */ + async load() { + if (this.ffmpeg.loaded) throw new Error('FFmpeg already loaded') + await this.ffCore.load(this.ffmpeg) + this.ffmpeg.on("log", this.logEventCallback) + console.log('FFMpegHooks loaded') + } + + /** + * Sets the progress callback function. + * @param callback - The callback function to be called on progress events. + */ + onProgress(callback: FFMpegProgressCallback) { + const listener: ProgressEventCallback = (event) => { + callback({ ...event, stage: this.stage }) + } + if (this.progressCallback) this.ffmpeg.off("progress", this.progressCallback) + this.ffmpeg.on("progress", listener) + this.progressCallback = listener + } + + /** + * Fixes the information of the input file using FFmpeg. + * @param input - The input file as a Blob. + * @param ext - The file extension of the output file (default: 'mp4'). + * @param copy - Indicates whether to copy the input file or not (default: true). + * @returns A Promise that resolves to the fixed file as an ArrayBufferLike object. + */ + async fixInfoAndCut(input: Blob, duration: number, ext: string = 'mp4'): Promise { + const inputFile = `input.${ext}` + const outputFile = `output.${ext}` + const middleFile = `output-uncut.${ext}` + const needCut = duration > 0 + console.debug('reading file size: ', formatBytes(input.size)) + const original = await input.arrayBuffer() + const cutArgs = [...this.ffCore.args, '-b:v', '0', '-r', '60'] + const fixArgs = needCut ? ['-c', 'copy'] : cutArgs // 如需剪輯,則在第一次執行一律使用快速編譯 + await this.ffmpeg.writeFile(inputFile, new Uint8Array(original)) + this.stage = 'fix' + console.debug('fixArg: ', fixArgs) + await this.ffmpeg.exec(['-i', inputFile, ...fixArgs, middleFile]) + if (needCut) { + this.stage = 'cut' + console.debug('cutArg: ', cutArgs) + await this.ffmpeg.exec(['-sseof', `-${duration * 60}`, '-i', middleFile, ...cutArgs, outputFile]) + } + const data = await this.ffmpeg.readFile(needCut ? outputFile : middleFile) + return (data as Uint8Array).buffer + } + + /** + * Terminates FFmpeg and cleans up the callbacks. + * @throws Error if FFmpeg is not loaded. + */ + terminate() { + if (!this.ffmpeg.loaded) throw new Error('FFmpeg not loaded') + if (this.progressCallback) this.ffmpeg.off("progress", this.progressCallback) + this.ffmpeg.off("log", this.logEventCallback) + this.ffmpeg.terminate() + } + + [Symbol.dispose](): void { + if (!this.ffmpeg.loaded) return + this.terminate() + console.log('FFMpegHooks disposed') + } + + get core() { + return this.ffmpeg + } +} + + +/** + * Custom hook for using FFMpeg. + * + * @param errCallback - Optional callback function to handle errors. Defaults to console.error. + * @returns An object containing a promise that resolves to FFMpegHooks instance. + * + * @example + * ```typescript + * const { ffmpeg } = useFFMpeg((err) => { + * // Handle error + * }); + * + * useEffect(() => { + * ffmpeg.then((ff) => { + * // Use FFMpegHooks instance + * }); + * }, [ffmpeg]); + * ``` + */ +export function useFFMpeg(errCallback: (err: Error) => void = console.error): { ffmpeg: Promise } { + + const ffmpegHooks = useRef() + + const ffmpeg = useMemo(() => (async () => { + if (!ffmpegHooks.current) { + ffmpegHooks.current = new FFMpegHooks() + await ffmpegHooks.current.load() + } + return ffmpegHooks.current + })(), []) + + useAsyncEffect( + async () => ffmpeg, + async (ff) => ff.terminate(), + errCallback, + [ffmpeg] + ) + + return { ffmpeg } +} \ No newline at end of file diff --git a/src/hooks/forwarder.ts b/src/hooks/forwarder.ts index 555bc8be..411cc633 100644 --- a/src/hooks/forwarder.ts +++ b/src/hooks/forwarder.ts @@ -2,9 +2,10 @@ import { getForwarder, type ChannelType, type ForwardData, - type ForwardResponse} from '~background/forwards' + type ForwardResponse +} from '~background/forwards' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' /** * `useForwarder` is a React hook that creates a forwarder for sending and receiving messages between different parts of a Chrome extension. @@ -30,21 +31,23 @@ import { useEffect } from 'react' export function useForwarder(key: K, target: ChannelType) { type R = ForwardResponse - const removeFunc = new Set<() => void>() + const removeFunc = new Set() + + const forwarder = useMemo(() => getForwarder(key, target), [key, target]) useEffect(() => { return () => { removeFunc.forEach(fn => fn()) } - }, []) - - const forwarder = getForwarder(key, target) + }, [forwarder]) - return { - addHandler: (handler: (data: R) => void) => { - removeFunc.add(forwarder.addHandler(handler)) + return useMemo(() => ({ + addHandler: (handler: (data: R) => void): VoidCallback => { + const remover = forwarder.addHandler(handler) + removeFunc.add(remover) + return remover // auto remove on unmount or manual remove }, sendForward: forwarder.sendForward - } + }), [forwarder]) } \ No newline at end of file diff --git a/src/hooks/life-cycle.ts b/src/hooks/life-cycle.ts new file mode 100644 index 00000000..79d8c242 --- /dev/null +++ b/src/hooks/life-cycle.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Hook to run an async effect on mount and another on unmount. + * + * edited from: https://marmelab.com/blog/2023/01/11/use-async-effect-react.html + */ +export const useAsyncEffect = ( + mountCallback: () => Promise, + unmountCallback: (r: R) => Promise, + errorCallback: (error: any) => void, + deps: any[] = [], +): void => { + const isMounted = useRef(false); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + let cleaner = null; + let ignore = false; + let mountSucceeded = false; + + (async () => { + await Promise.resolve(); // wait for the initial cleanup in Strict mode - avoids double mutation + if (!isMounted.current || ignore) { + return; + } + try { + cleaner = await mountCallback(); + mountSucceeded = true; + if (!isMounted.current || ignore) { + // Component was unmounted before the mount callback returned, cancel it + unmountCallback(cleaner); + } + } catch (error) { + if (!isMounted.current) return; + errorCallback(error); + } + })(); + + return () => { + ignore = true; + if (mountSucceeded) { + unmountCallback(cleaner) + .then(() => { + if (!isMounted.current) return; + }) + .catch((error: unknown) => { + if (!isMounted.current) return; + errorCallback(error); + }); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +}; + + + +export function useTimeoutElement(before: JSX.Element, after: JSX.Element, timeout: number) { + const [element, setElement] = useState(before); + + useEffect(() => { + const timer = setTimeout(() => { + setElement(after); + }, timeout); + + return () => clearTimeout(timer); + }, [after, before, timeout]); + + return element; +} \ No newline at end of file diff --git a/src/hooks/promise.ts b/src/hooks/promise.ts index 185178bc..ce548b48 100644 --- a/src/hooks/promise.ts +++ b/src/hooks/promise.ts @@ -58,6 +58,7 @@ export function usePromise(promise: Promise | (() => Promise), deps: an dispatch({ type: "SUCCESS", payload: data }) }) .catch((error) => { + console.warn(error) dispatch({ type: "ERROR", payload: error }) }) }, [promise, ...deps]) diff --git a/src/hooks/stream.ts b/src/hooks/stream.ts new file mode 100644 index 00000000..d37c004a --- /dev/null +++ b/src/hooks/stream.ts @@ -0,0 +1,172 @@ + +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import type { ChannelType } from "~background/forwards"; +import type { ForwardBody } from "~background/forwards/stream-content"; +import type { VideoInfo } from '~players/index'; +import { deserializeStringToBlob, serializeBlobAsString, splitBlob } from "~utils/binary"; +import { useForwarder } from "./forwarder"; + + +export type StreamResult = { + duration: number + videoInfo: VideoInfo + filename: string + content: Blob[] +} + +export type StreamContent = { + id: string + duration: number + videoInfo: VideoInfo + filename: string + blob: Blob | Blob[] +} + +export function useShardSender(channel: ChannelType) { + const forwarder = useForwarder('stream-content', channel) + + const waitForReady = useCallback(async (id: string) => { + return new Promise((res, rej) => { + const remove = forwarder.addHandler((body: ForwardBody) => { + if (body.stage === 'ready' && body.id === id) { + remove() + res() + } + }) + setTimeout(() => rej(new Error('timeout')), 1000 * 60) // 1 minute + }) + }, [channel]) + + return async (channel: ChannelType, body: StreamContent) => { + console.debug('splitting chunks...') + const chunks = Array.isArray(body.blob) ? body.blob : splitBlob(body.blob, 1024 * 1024) // 1MB + console.debug('splited to chunks: ', chunks.length) + console.debug('their average size: ', chunks.reduce((a, b) => a + b.size, 0) / chunks.length) + try { + console.debug('waiting for ready signal....') + await waitForReady(body.id) + console.debug('sending init...') + forwarder.sendForward(channel, { + stage: 'init', + id: body.id, + duration: body.duration, + videoInfo: body.videoInfo, + filename: body.filename, + totalChunks: chunks.length + }) + console.debug('sending chunks...') + for (let i = 0; i < chunks.length; i++) { + forwarder.sendForward(channel, { + stage: 'data', + id: body.id, + order: i, + content: await serializeBlobAsString(chunks[i]) + }) + } + console.debug('sending end...') + forwarder.sendForward(channel, { + stage: 'end', + id: body.id, + }) + } catch (error: Error | any) { + console.error('error: ', error) + forwarder.sendForward(channel, { + stage: 'error', + id: body.id, + message: error?.message ?? error + }) + throw error + } + } +} + + +type ChunkInfo = { + chunk: Blob + order: number +} + + +export type ReceiveInfo = { + info: StreamResult | null + progress: number + error: Error + ready: (channel: ChannelType) => void +} + +export function useShardReceiver(id: string, channel: ChannelType): ReceiveInfo { + + const forwarder = useForwarder('stream-content', channel) + const [info, setInfo] = useState() + const chunks = useRef([]) + const [total, setTotal] = useState(0) + const [realProgress, setProgress] = useState(0) + const progress = useDeferredValue(realProgress) + const [error, setError] = useState() + + useEffect(() => { + + console.debug('listening to stream content...') + forwarder.addHandler((body) => { + if (body.id !== id) return + console.debug('received stage: ', body.stage) + if (body.stage === 'init') { + setInfo(() => ({ + duration: body.duration, + videoInfo: body.videoInfo, + filename: body.filename, + content: [] + })) + setTotal(body.totalChunks) + } else if (body.stage === 'data') { + console.debug('received chunks: ', body.content.length, ' order: ', body.order) + const blob = deserializeStringToBlob(body.content) + chunks.current.push({ + chunk: blob, + order: body.order + }) + setProgress((prev) => prev + 1) + console.debug('fetched chunk: ', body.order) + } else if (body.stage === 'end') { + setInfo((prev) => ({ + ...prev, + content: chunks.current + .sort((a, b) => a.order - b.order) + .map((c) => c.chunk) + .flat() + })) + } else if (body.stage === 'error') { + setError(new Error(body.message)) + } + }) + + }, [channel]) + + const ready = useCallback((channel: ChannelType) => { + console.debug('send ready signal...') + forwarder.sendForward(channel, { + stage: 'ready', + id + }, { url: '*://live.bilibili.com/*' }) + }, []) + + const streamInfo = useMemo(() => { + console.debug( + 'progress=', + progress, + '/', + total, + '*100', + '=', + progress / total * 100 + ) + return { + info: progress < total ? null : info, // null while in progress + progress: progress / total * 100, + error, + ready, + } + }, [info, progress, total, error, ready]) + + return streamInfo +} \ No newline at end of file diff --git a/src/hooks/styles.ts b/src/hooks/styles.ts new file mode 100644 index 00000000..b63b3425 --- /dev/null +++ b/src/hooks/styles.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; + + +export function useComputedStyle(element: Element): CSSStyleDeclaration { + return useMemo(() => window.getComputedStyle(element), [element]); +} + +export function useContrast(background: Element) { + const { backgroundColor: rgb } = useComputedStyle(background); + return useMemo(() => { + const r = parseInt(rgb.slice(4, rgb.indexOf(','))); + const g = parseInt(rgb.slice(rgb.indexOf(',', rgb.indexOf(',') + 1))); + const b = parseInt(rgb.slice(rgb.lastIndexOf(',') + 1, -1)); + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return { + brightness, + color: brightness > 125 ? 'black' : 'white', + dark: brightness > 125 + }; + }, [rgb]); +} \ No newline at end of file diff --git a/src/logger.ts b/src/logger.ts index 18ebb0fb..6d1fda5a 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,4 @@ -const debug = process.env.CI || process.env.NODE_ENV !== 'production' +const debug = process.env.DEBUG || process.env.NODE_ENV !== 'production' console.info = console.info.bind(console, '[bilibili-vup-stream-enhancer]') diff --git a/src/players/flv.ts b/src/players/flv.ts index 5be79ced..ce0c1823 100644 --- a/src/players/flv.ts +++ b/src/players/flv.ts @@ -1,55 +1,126 @@ import flvJs from 'mpegts.js'; -import type { StreamPlayer } from "~players"; +import { type VideoInfo } from "~players"; +import { StreamPlayer } from '~types/media'; -class FlvPlayer implements StreamPlayer { +class FlvPlayer extends StreamPlayer { private player: flvJs.Player - private room: string - - constructor(room: string) { - this.room = room - } + private streamController: AbortController get isSupported(): boolean { return flvJs.isSupported() } - loadAndPlay(url: string, container: HTMLMediaElement): Promise { + get internalPlayer(): any { + return this.player + } + + get videoInfo(): VideoInfo { + return { + mimeType: 'video/x-flv', + extension: 'flv' + } + } + + play(url: string, media?: HTMLMediaElement): Promise { + + if (!media) { + // if no media, just load the stream via fetch + return this.load(url) + } + this.player = flvJs.createPlayer({ type: 'flv', isLive: true, - url, - cors: true, - withCredentials: true + url }, { stashInitialSize: 1024 * 1024, autoCleanupSourceBuffer: true, headers: { - 'Origin': 'https://live.bilibili.com', - 'Referer': `https://live.bilibili.com/${this.room}` + 'Origin': 'https://live.bilibili.com' } }) - this.player.attachMediaElement(container) + this.player.attachMediaElement(media) this.player.load() this.player.play() return new Promise((res, rej) => { - this.player.on('media_info', res) + this.player.on('media_info', () => { + //this.hijackBaseLoader() + this.emit('loaded', {}) + res() + }) this.player.on('error', (e) => { console.warn('flv error: ', e) this.player.detachMediaElement() + this.emit('error', e) rej(e) }) }) } - async stopAndDestroy(): Promise { - this.player.detachMediaElement() - this.player.destroy() + async load(url: string): Promise { + try { + this.streamController = new AbortController() + const res = await fetch(url, { signal: this.streamController.signal }) + if (!res.ok) throw new Error('fetch error: ' + res.statusText) + if (res.bodyUsed) throw new Error('response body already used') + const reader = res.body.getReader() + const pump = async () => { + try { + const { done, value } = await reader.read() + if (done) return + this.emit('buffer', value.buffer) + await pump() + } catch (err: Error | any) { + if (err.name === 'AbortError') { + return + } + this.emit('error', err) + console.warn('error while reading stream segment: ', err) + } + } + pump() + this.emit('loaded', {}) + } catch (err: Error | any) { + this.emit('error', err) + throw err + } } - get internalPlayer(): any { - return this.player + stopAndDestroy() { + this.clearHandlers() + // for load case + if (this.streamController) { + this.streamController.abort() + this.streamController = null + } + // for play case + if (this.player) { + this.player.detachMediaElement() + this.player.destroy() + this.player = null + } } + + // currently not working, so use the fetch method instead + // hijackBaseLoader(): void { + // const flv = this + // const player = this.player as any + // if (player.TAG !== 'MSEPlayer') { + // console.error('Not a MSEPlayer, hijack failed') + // return + // } + // const baseLoader = player?._transmuxer?._controller?._ioctl?._loader + // if (!baseLoader) { + // console.error('No base loader found, hijack failed') + // return + // } + // const _onDataArrival = baseLoader._onDataArrival + // baseLoader._onDataArrival = function (chunks: ArrayBuffer, byteStart: number, receivedLength: number) { + // _onDataArrival(chunks, byteStart, receivedLength) + // flv.emit('buffer', chunks) + // } + // } } diff --git a/src/players/hls.ts b/src/players/hls.ts index 8183f4b2..7e6199f9 100644 --- a/src/players/hls.ts +++ b/src/players/hls.ts @@ -1,8 +1,8 @@ -import type { StreamPlayer } from "~players"; -import Hls from 'hls.js' +import Hls from 'hls.js'; +import { type VideoInfo } from "~players"; +import { StreamPlayer } from '~types/media'; - -class HlsPlayer implements StreamPlayer { +class HlsPlayer extends StreamPlayer { private player: Hls @@ -10,57 +10,94 @@ class HlsPlayer implements StreamPlayer { return Hls.isSupported() } - loadAndPlay(url: string, video: HTMLMediaElement): Promise { + get internalPlayer(): Hls { + return this.player + } + + get videoInfo(): VideoInfo { + return { + mimeType: 'video/mp4', + extension: 'mp4' + } + } + + play(url: string, media?: HTMLMediaElement): Promise { + + if (!media) { + // create a hidden video element + media = document.createElement('video') + media.style.display = 'none' + media.volume = 0 + media.muted = true + media.autoplay = true + } + this.player = new Hls({ + enableWorker: true, + liveDurationInfinity: true, lowLatencyMode: true, + maxBufferLength: Infinity }) + return new Promise((res, rej) => { this.player.once(Hls.Events.MEDIA_ATTACHED, () => { console.log('video and hls.js are now bound together !') - res() + }) this.player.once(Hls.Events.MANIFEST_PARSED, (event, data) => { console.log('manifest loaded, found ' + data.levels.length + ' quality level', data) + this.emit('loaded', {}) + res() + }) + + this.player.on(Hls.Events.BUFFER_APPENDING, (event, data) => { + this.emit('buffer', data.data.buffer) }) this.player.loadSource(url); - this.player.attachMedia(video); + this.player.attachMedia(media); - this.player.once(Hls.Events.ERROR, (event, data) => { - console.warn('hls error: ', data) + let retryCount = 0 + this.player.on(Hls.Events.ERROR, (event, data) => { + console.warn('hls error: ', data.error, data.errorAction, data.reason) + console.warn('retry count: ', retryCount++) + if (retryCount >= 3) { + console.error('retry count exceeded, stop and destroy player') + this.stopAndDestroy() + this.emit('error', data.error) + return + } + if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.MEDIA_ERROR: console.log('fatal media error encountered, try to recover'); - this.player.recoverMediaError(); + if (media) this.player.recoverMediaError() break; case Hls.ErrorTypes.NETWORK_ERROR: console.error('fatal network error encountered', data); + this.player.startLoad() break; default: // cannot recover + console.error('fatal error encountered, cannot recover'); this.player.destroy(); + this.emit('error', data.error) break; } - rej(data) + rej(data.error) } }) }) } - stopAndDestroy(): Promise { - return new Promise((res,) => { - this.player.detachMedia() - this.player.on(Hls.Events.MEDIA_DETACHED, () => { - this.player.destroy() - res() - }) - }) - } - - get internalPlayer(): Hls { - return this.player + stopAndDestroy() { + this.clearHandlers() + this.player.stopLoad() + this.player.detachMedia() + this.player.destroy() + this.player = null } } diff --git a/src/players/index.ts b/src/players/index.ts index 82f90474..cb039236 100644 --- a/src/players/index.ts +++ b/src/players/index.ts @@ -1,24 +1,21 @@ import type { StreamUrls } from '~background/messages/get-stream-urls' import flv from './flv' import hls from './hls' +import type { StreamPlayer } from '~types/media' export type EventType = keyof StreamParseEvent export type StreamParseEvent = { 'loaded': {}, - 'error': Error + 'error': Error, + 'buffer': ArrayBuffer } -export interface StreamPlayer { - - get isSupported(): boolean - - loadAndPlay(url: string, video: HTMLMediaElement): Promise - - get internalPlayer(): any - - stopAndDestroy(): Promise +export type EventHandler = (event: StreamParseEvent[E]) => void +export type VideoInfo = { + mimeType: string + extension: string } export type PlayerType = keyof typeof players @@ -28,17 +25,25 @@ const players = { flv } -async function loadStream(roomId: string, urls: StreamUrls, video: HTMLVideoElement): Promise { + +export type PlayerOptions = { + type?: PlayerType + codec?: 'avc' | 'hevc' +} + +async function loadStream(urls: StreamUrls, video: HTMLVideoElement, options: PlayerOptions = { codec: 'avc' }): Promise { for (const url of urls) { + if (options.type && url.type !== options.type) continue + if (options.codec && url.codec !== options.codec) continue const Player = players[url.type] - const player = new Player(roomId) - console.info('trying to use: ', url) + const player = new Player() + console.info(`trying to use type ${url.type} player to load: `, url.url, ' quality: ', url.quality, ' codec: ', url.codec) if (!player.isSupported) { console.warn(`Player ${url.type} is not supported, skipped: `, url) continue } try { - await player.loadAndPlay(url.url, video) + await player.play(url.url, video) return player } catch (err: Error | any) { console.error(`Player failed to load: `, err, ', from: ', url) @@ -48,4 +53,27 @@ async function loadStream(roomId: string, urls: StreamUrls, video: HTMLVideoElem throw new Error('No player is supported') } +export async function recordStream(urls: StreamUrls, handler: EventHandler<'buffer'>, options: PlayerOptions = { codec: 'avc' }): Promise { + for (const url of urls) { + if (options.type && url.type !== options.type) continue + if (options.codec && url.codec !== options.codec) continue + const Player = players[url.type] + const player = new Player() + console.info(`trying to use type ${url.type} player to record: `, url.url, ' quality: ', url.quality, ' codec: ', url.codec) + if (!player.isSupported) { + console.warn(`Player ${url.type} is not supported, skipped: `, url) + continue + } + try { + await player.play(url.url) + player.on('buffer', handler) + return player + } catch (err: Error | any) { + console.error(`Player failed to load: `, err, ', from: ', url) + continue + } + } + throw new Error('No recorder is supported') +} + export default loadStream \ No newline at end of file diff --git a/src/settings/components/HotKeyInput.tsx b/src/settings/components/HotKeyInput.tsx new file mode 100644 index 00000000..62cf4d2c --- /dev/null +++ b/src/settings/components/HotKeyInput.tsx @@ -0,0 +1,66 @@ +import { IconButton, Input, type InputProps } from "@material-tailwind/react" +import type { Keys } from "@react-hooks-library/core" +import { useState, type RefAttributes } from "react" + +export type HotKey = { + key: string + ctrlKey: boolean + shiftKey: boolean +} + +export type HotKeyInputProps = { + onChange: (value: HotKey) => void + value: HotKey + optional?: boolean +} & RefAttributes & Omit + +export function HotKeyInput(props: HotKeyInputProps): JSX.Element { + + const { optional: opt, value, onChange, ...attrs } = props + const optional = opt ?? true + + const [ listening, setListening ] = useState(false) + + const onListenKeyInput = () => { + const listener = (e: KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + if (['Escape', 'Control', 'Shift'].includes(e.key)) return + onChange?.(e) + window.removeEventListener('keydown', listener) + setListening(false) + } + window.addEventListener('keydown', listener) + setListening(true) + } + + const keyDisplay = (value.ctrlKey ? 'Ctrl+' : '') + (value.shiftKey ? 'Shift+' : '') + value.key.toUpperCase() + + return ( +
+
+ + + + + + +
+
+ ) +} + +export default HotKeyInput \ No newline at end of file diff --git a/src/settings/features/index.ts b/src/settings/features/index.ts index 23c18085..a9f2a36c 100644 --- a/src/settings/features/index.ts +++ b/src/settings/features/index.ts @@ -1,6 +1,7 @@ import * as jimaku from './jimaku' import * as superchat from './superchat' +import * as recorder from './recorder' import type { FeatureType } from '~features' import type { TableType } from "~database" @@ -23,7 +24,8 @@ export type FeatureSettings = typeof featureSettings const featureSettings = { jimaku, - superchat + superchat, + recorder } export default (featureSettings as { [K in FeatureType]: FeatureSettings[K] }) diff --git a/src/settings/features/jimaku/components/JimakuFragment.tsx b/src/settings/features/jimaku/components/JimakuFragment.tsx index 4c7abb0f..7cd9aa33 100644 --- a/src/settings/features/jimaku/components/JimakuFragment.tsx +++ b/src/settings/features/jimaku/components/JimakuFragment.tsx @@ -116,20 +116,22 @@ function JimakuFragment({ state, useHandler }: StateProxy): JSX.El { value: 'bottom', label: '下到上' }, ]} /> - - - - +
+ + + + +
) } diff --git a/src/settings/features/jimaku/index.tsx b/src/settings/features/jimaku/index.tsx index 5f1dbe31..63a97e9f 100644 --- a/src/settings/features/jimaku/index.tsx +++ b/src/settings/features/jimaku/index.tsx @@ -77,17 +77,19 @@ function JimakuFeatureSettings({ state, useHandler }: StateProxy - - } - /> - - +
+ + } + /> + + +
{zones.map(({ Zone, title, key }) => { const stateProxy = asStateProxy(useBinding(state[key], true)) diff --git a/src/settings/features/recorder/index.tsx b/src/settings/features/recorder/index.tsx new file mode 100644 index 00000000..43e854b8 --- /dev/null +++ b/src/settings/features/recorder/index.tsx @@ -0,0 +1,143 @@ +import { List, Switch, Typography } from "@material-tailwind/react" +import { Fragment, type ChangeEvent } from "react" +import type { RecorderType } from "~features/recorder/recorders" +import type { StateProxy } from "~hooks/binding" +import { HotKeyInput, type HotKey } from "~settings/components/HotKeyInput" +import Selector from "~settings/components/Selector" +import type { FeatureSettingsDefinition } from ".." +import SwitchListItem from "~settings/components/SwitchListItem" +import type { PlayerType } from "~players" +import { toast } from "sonner/dist" + +export const title: string = '快速切片' + +export const define: FeatureSettingsDefinition = { + offlineTable: false +} + +export type FeatureSettingSchema = { + duration: number + outputType?: PlayerType + recordFix: 'copy' | 'reencode' + hotkeyClip: HotKey + mechanism: RecorderType + hiddenUI: boolean + threads: number + overflow: 'limit' | 'skip' +} + +export const defaultSettings: Readonly = { + duration: 5, + outputType: 'hls', + recordFix: 'copy', + hotkeyClip: { + key: 'x', + ctrlKey: true, + shiftKey: false, + }, + mechanism: 'buffer', + hiddenUI: false, + threads: 0.5, + overflow: 'limit' +} + +export function RecorderFeatureSettings({ state, useHandler }: StateProxy): JSX.Element { + + const onChangeHotKey = (key: HotKey) => { + state.hotkeyClip.key = key.key + state.hotkeyClip.ctrlKey = key.ctrlKey + state.hotkeyClip.shiftKey = key.shiftKey + } + + const bool = useHandler, boolean>(e => e.target.checked) + + return ( + + + data-testid="record-output-type" + label="输出格式" + value={state.outputType} + onChange={e => state.outputType = e} + options={[ + { value: 'hls', label: 'MP4' }, + { value: 'flv', label: 'FLV' }, + { value: undefined, label: '随机' } + ]} + /> + + data-testid="record-fix" + label="录制后编译方式" + value={state.recordFix} + onChange={e => state.recordFix = e} + options={[ + { value: 'copy', label: '快速编译' }, + { value: 'reencode', label: '完整编译 (速度较慢)' } + ]} + /> + + data-testid="record-duration" + label="录制时长" + value={state.duration} + onChange={e => state.duration = e} + options={[ + { value: 5, label: '约前5分钟' }, + { value: 10, label: '约前10分钟' }, + { value: 15, label: '约前15分钟' }, + { value: -1, label: '手动录制' } + ]} + /> + + data-testid="record-threads" + label="最大线程占用" + value={state.threads} + disabled={state.recordFix !== 'reencode'} + onChange={e => state.threads = e} + options={[ + { value: 0.15, label: '15%' }, + { value: 0.25, label: '25%' }, + { value: 0.35, label: '35%' }, + { value: 0.45, label: '45%' }, + { value: 0.5, label: '50%' }, + ]} + /> + + data-testid="record-overflow" + label="超出 2GB 大小时" + value={state.overflow} + onChange={e => state.overflow = e} + options={[ + { value: 'limit', label: '从前面的时间段裁减' }, + { value: 'skip', label: '跳过 ffmpeg 编译 (资讯损坏状态)' } + ]} + /> +
+ +
+
+ + + 隐藏录制按钮 + + + 隐藏后,你仍可通过热键使用本功能。 + + + } + checked={state.hiddenUI} + onChange={bool('hiddenUI')} + /> +
+
+ ) +} + +export default RecorderFeatureSettings \ No newline at end of file diff --git a/src/settings/features/superchat/index.tsx b/src/settings/features/superchat/index.tsx index 01b8ef91..2325e2e9 100644 --- a/src/settings/features/superchat/index.tsx +++ b/src/settings/features/superchat/index.tsx @@ -1,5 +1,5 @@ import { Switch, Typography } from "@material-tailwind/react" -import { type ChangeEvent } from "react" +import { Fragment, type ChangeEvent } from "react" import type { StateProxy } from "~hooks/binding" import ColorInput from "~settings/components/ColorInput" import type { HexColor } from "~types/common" @@ -30,7 +30,7 @@ function SuperchatFeatureSettings({ state, useHandler }: StateProxy, boolean>((e) => e.target.checked) return ( -
+
@@ -43,7 +43,7 @@ function SuperchatFeatureSettings({ state, useHandler }: StateProxy
-
+ ) } diff --git a/src/settings/fragments/developer.tsx b/src/settings/fragments/developer.tsx index d0323787..d2e2ddec 100644 --- a/src/settings/fragments/developer.tsx +++ b/src/settings/fragments/developer.tsx @@ -8,6 +8,7 @@ import { setSettingStorage } from '~utils/storage'; export type SettingSchema = { elements: { + upperHeaderArea: string; upperButtonArea: string; webPlayer: string; danmakuArea: string; @@ -34,6 +35,7 @@ export type SettingSchema = { export const defaultSettings: Readonly = { elements: { + upperHeaderArea: 'div.upper-row > div.left-ctnr.left-header-area', // 上方标题界面元素 upperButtonArea: '.lower-row .left-ctnr', // 上方按钮界面元素 webPlayer: '.web-player-danmaku', // 播放器元素 danmakuArea: '.danmaku-item-container', // 弹幕容器元素 @@ -79,6 +81,10 @@ type ElementDefinerList = { const elementDefiners: ElementDefinerList = { "元素捕捉": [ + { + label: "上方标题界面元素", + key: "elements.upperHeaderArea" + }, { label: "上方按钮界面元素", key: "elements.upperButtonArea" diff --git a/src/settings/fragments/features.tsx b/src/settings/fragments/features.tsx index 0930a88c..87ed45e0 100644 --- a/src/settings/fragments/features.tsx +++ b/src/settings/fragments/features.tsx @@ -29,7 +29,7 @@ export type SettingSchema = { export const defaultSettings: Readonly = { - enabledFeatures: featureTypes, + enabledFeatures: [ 'superchat', 'jimaku' ], enabledRecording: [], common: { enabledPip: false, @@ -116,7 +116,7 @@ function FeatureSettings({ state, useHandler }: StateProxy): JSX. ): JSX. } crossOrigin={'annoymous'} checked={state.enabledFeatures.includes(f)} onChange={e => toggle(f)} />
- - {setting.define.offlineTable !== false && ( + {setting.define.offlineTable !== false && ( + ): JSX. } /> - )} - - {Component && } + + )} + {Component && ( +
+ +
+ )}
(null) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const { + info, + progress: infoProgress, + error: infoError, + ready + } = useShardReceiver(ffmpegID, 'pages') + + useEffect(() => { + if (result || error) return + const beforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault() + e.returnValue = '' + } + window.addEventListener('beforeunload', beforeUnload) + return () => window.removeEventListener('beforeunload', beforeUnload) + }, [result, error]) + + useAsyncEffect( + async () => { + console.info('info is null: ', !info) + console.info('ffmpeg is null: ', !ffmpeg) + if (!ffmpeg) return + if (!info) { + ready('content-script') + console.info('正在等待视频数据...') + return + } + console.info('开始编译视频...') + ffmpeg.onProgress(progress => { + console.info('received progress: ', progress) + setProgress(() => progress) + if (progress.progress > 0 && progress.progress < 1) { + setError(null) // 如果有进度,一律当没有错误 + } + }) + const original = new Blob(info.content, { type: info.videoInfo.mimeType }) + const fixed = await ffmpeg.fixInfoAndCut(original, info.duration, info.videoInfo.extension) + setResult(() => new Blob([fixed], { type: info.videoInfo.mimeType })) + downloadBlob(new Blob([fixed], { type: info.videoInfo.mimeType }), info.filename) + }, + async () => { }, + (err) => { + toast.error('编译视频时发生错误') + console.error('unexpected error: ', err) + setError(err) + }, + [ffmpeg, info] + ) + + if (error) { + return ( +
+

编译视频时发生错误

+
{error?.message ?? new String(error)}
+
+ ) + } else if (infoError) { + return ( +
+

获取视频数据时发生错误

+
{infoError?.message ?? new String(infoError)}
+
+ ) + } + + if (!info) { + return ( +
+
+ + + +
+

正在等待视频数据...

+ +
+ ) + } + + if (!progress) { + return ( +
+ +
编译准备中...
+
+ ) + } + + const action = progress.stage === 'fix' ? '修复' : '编译' + + if (progress.progress === 1) { + return ( +
+
+ + + +
+

视频已{action}完成。

+

{info.filename}

+
+ ) + } + + console.debug('progress: ', progress) + const progressInvalid = isNaN(progress.progress) || !isFinite(progress.progress) + return ( +
+
+
+ +
+
+ {`${action}视频中... ${progressInvalid ? '' : `(${Math.round(progress.progress * 10000) / 100}%)`}`} +
+
+ 时间段: {toTimer(Math.round(progress.time / 1000000))} +
+
+ {!progressInvalid && } +
+ ) + +} + + +function App(): JSX.Element { + + const { ffmpeg } = useFFMpeg() + + if (!ffmpegID) { + return ( +
+

无效的请求

+
+ ) + } + + return ( + +
+ + +
+ +
正在加载 FFMpeg...
+
+
+ + {err => ( +
+

FFMpeg 加载失败

+
{err?.message ?? err}
+
+ )} +
+ + {(ff) => useTimeoutElement( + ( +
+
+ + + +
+

FFMpeg 已成功加载。

+
+ ), + , + 2000 + )} +
+
+
+
+ ) +} + +export default App \ No newline at end of file diff --git a/src/tabs/stream.tsx b/src/tabs/stream.tsx index 706a72ee..837782c6 100644 --- a/src/tabs/stream.tsx +++ b/src/tabs/stream.tsx @@ -23,8 +23,9 @@ import type { StreamUrls } from '~background/messages/get-stream-urls'; import BJFThemeProvider from '~components/BJFThemeProvider'; import PromiseHandler from '~components/PromiseHandler'; import { useForwarder } from '~hooks/forwarder'; -import loadStream, { type StreamPlayer } from '~players'; +import loadStream from '~players'; import { sendMessager } from '~utils/messaging'; +import { useAsyncEffect } from '~hooks/life-cycle'; const urlParams = new URLSearchParams(window.location.search); const roomId = urlParams.get('roomId'); @@ -42,44 +43,39 @@ function MonitorApp({ urls }: { urls: StreamUrls }): JSX.Element { const containerRef = useRef(null) const videoRef = useRef(null) - const player = useRef(null) const danmaku = useRef(null) const danmakus = useRef([]) const danmakuForwarder = useForwarder('danmaku', 'pages') const jimakuForwarder = useForwarder('jimaku', 'pages') - useEffect(() => { - - console.info('urls: ', urls) - loadStream(roomId, urls, videoRef.current) - .then(p => { - player.current = p - danmaku.current = new NDanmaku(containerRef.current, 'bjf-danmaku') - }) - .catch(e => { - console.error(e) - alert('加载播放器失败, 请刷新页面') - }) - return () => { + useAsyncEffect( + async () => { + console.info('urls: ', urls) + const p = await loadStream(urls, videoRef.current) + danmaku.current = new NDanmaku(containerRef.current, 'bjf-danmaku') + p.on('error', console.error) + return p + }, + async (p) => { if (danmaku.current) { danmaku.current.clear() } else { console.warn('danmaku is not initialized, skip cleanup') } - if (player.current) { - player.current.stopAndDestroy() - .then(() => { - console.info('player destroyed') - player.current = null - }) + if (p) { + p.stopAndDestroy() + console.info('player destroyed') } else { console.warn('player is not initialized, skip cleanup') } - - } - - }, [urls]) + }, + (err) => { + console.error(err) + alert('加载播放器失败, 请刷新页面') + }, + [urls] + ) useInterval(() => { if (danmakus.current.length === 0) return diff --git a/src/types/extends/global.d.ts b/src/types/extends/global.d.ts index fbd1b27d..7c04564b 100644 --- a/src/types/extends/global.d.ts +++ b/src/types/extends/global.d.ts @@ -34,6 +34,14 @@ declare global { webkitallowfullscreen: boolean } + interface FileSystemDirectoryHandle { + [Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]>; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + keys(): AsyncIterableIterator; + values(): AsyncIterableIterator; + remove(options?: { recursive: boolean }): Promise; + } + } export { } \ No newline at end of file diff --git a/src/types/media/index.ts b/src/types/media/index.ts new file mode 100644 index 00000000..34577615 --- /dev/null +++ b/src/types/media/index.ts @@ -0,0 +1,2 @@ +export * from './player' +export * from './recorder' \ No newline at end of file diff --git a/src/types/media/player.ts b/src/types/media/player.ts new file mode 100644 index 00000000..eb35b34b --- /dev/null +++ b/src/types/media/player.ts @@ -0,0 +1,43 @@ + +import type { EventType, VideoInfo, StreamParseEvent, EventHandler } from "~players" + +export abstract class StreamPlayer { + + protected readonly eventHandlers: Map>> = new Map() + + abstract get internalPlayer(): any + + abstract get isSupported(): boolean + + abstract get videoInfo(): VideoInfo + + abstract play(url: string, media?: HTMLMediaElement): Promise + + abstract stopAndDestroy(): void + + on(event: E, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event) + if (!handlers) { + this.eventHandlers.set(event, new Set([handler])) + return + } + handlers.add(handler) + } + + off(event: E, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event) + if (handlers) handlers.delete(handler) + } + + protected emit(event: E, payload: StreamParseEvent[E]): void { + const handlers = this.eventHandlers.get(event) + for (const handler of (handlers || [])) { + handler(payload) + } + } + + protected clearHandlers(): void { + this.eventHandlers.clear() + } + +} \ No newline at end of file diff --git a/src/types/media/recorder.ts b/src/types/media/recorder.ts new file mode 100644 index 00000000..a4ca1f2f --- /dev/null +++ b/src/types/media/recorder.ts @@ -0,0 +1,40 @@ +import type { StreamUrls } from "~background/messages/get-stream-urls" +import type { PlayerOptions, PlayerType } from "~players" +import type { ChunkData } from "~features/recorder/recorders" +import { formatBytes } from "~utils/binary" + +export abstract class Recorder { + + protected static readonly FFmpegLimit = 2 * 1024 * 1024 * 1024 // 2GB + + protected readonly room: string + protected readonly urls: StreamUrls + protected readonly options: PlayerOptions + protected recordedSize = 0 + + constructor(room: string, urls: StreamUrls, options: PlayerOptions) { + this.room = room + this.urls = urls + this.options = options + } + + abstract start(): Promise + + abstract loadChunkData(flush?: boolean): Promise + + abstract flush(): Promise + + abstract stop(): void + + abstract get recording(): boolean + + abstract set onerror(handler: (error: Error) => void) + + get fileSize(): string { + return formatBytes(this.recordedSize) + } + + get fileSizeMB(): number { + return this.recordedSize / (1024 * 1024) + } +} \ No newline at end of file diff --git a/src/utils/binary.ts b/src/utils/binary.ts new file mode 100644 index 00000000..bd6c4efb --- /dev/null +++ b/src/utils/binary.ts @@ -0,0 +1,85 @@ +/** + * Formats the given number of bytes into a human-readable string representation. + * @param bytes The number of bytes to format. + * @returns A string representing the formatted bytes. + */ +export function formatBytes(bytes: number): string { + const sizes = ["Bytes", "KB", "MB", "GB", "TB"] + + if (bytes == 0) { + return "0 Bytes" + } + + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + + if (i == 0) { + return bytes + " " + sizes[i] + } + + return (bytes / Math.pow(1024, i)).toFixed(2) + " " + sizes[i] +} + + +/** + * Splits a Blob into multiple smaller Blobs of a specified chunk size. + * + * @param blob - The Blob to be split. + * @param chunkSize - The size of each chunk in bytes. + * @returns An array of Blobs, each representing a chunk of the original Blob. + */ +export function splitBlob(blob: Blob, chunkSize: number = 10 * 1024 * 1024): Blob[] { + const chunks = [] + const size = blob.size + let offset = 0 + while (offset < size) { + const end = Math.min(offset + chunkSize, size) + const chunk = blob.slice(offset, end, blob.type) + chunks.push(chunk) + offset += chunkSize + } + return chunks +} + + +export async function serializeBlobAsString(blob: Blob): Promise { + console.info('reading blob content with size: ', blob.size) + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string; + const base64 = dataUrl.split(',')[1]; + resolve(base64); + } + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }); +} + +export function deserializeStringToBlob(base64: string): Blob { + const byteString = atob(base64); + const byteNumbers = new Array(byteString.length); + for (let i = 0; i < byteString.length; i++) { + byteNumbers[i] = byteString.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray]) +} + + +export async function serializeBlobAsNumbers(blob: Blob): Promise { + console.info('reading blob content with size: ', blob.size) + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const buffer = new Uint8Array(reader.result as ArrayBuffer) + resolve(Array.from(buffer)) + } + reader.onerror = () => reject(reader.error) + reader.readAsArrayBuffer(blob) + }); +} + +export function deserializeNumbersToBlob(numbers: number[]): Blob { + const buffer = new Uint8Array(numbers) + return new Blob([buffer]) +} \ No newline at end of file diff --git a/src/utils/file.ts b/src/utils/file.ts index 1112a02a..1da3092e 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -4,16 +4,27 @@ * @param content - The content of the file. * @param type - The MIME type of the file. Defaults to 'text/plain'. */ -export function download(filename: string, content: string, type: string = 'text/plain') { +export function download(filename: string, content: any | any[], type: string = 'text/plain') { + const file = new Blob(Array.isArray(content) ? content : [content], { type }) + downloadBlob(file, filename) +} + + +/** + * Downloads a Blob object as a file. + * + * @param blob - The Blob object to download. + * @param filename - The name of the file to be downloaded. + * @returns A Promise that resolves when the download is complete. + */ +export function downloadBlob(blob: Blob, filename: string) { const a = document.createElement('a') - const file = new Blob([content], { type }) - a.href = URL.createObjectURL(file) + a.href = URL.createObjectURL(blob) a.download = filename a.click() URL.revokeObjectURL(a.href) } - /** * Reads a file as JSON and returns a promise that resolves with the parsed JSON object. * @param file - The file to read. diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 4b0a51de..5a54e667 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -21,10 +21,11 @@ export function toTimer(secs: number): string { const min = Math.floor(secs / 60) secs %= 60 + const mh = hr > 9 ? `${hr}` : `0${hr}` const mu = min > 9 ? `${min}` : `0${min}` const ms = secs > 9 ? `${secs}` : `0${secs}` - return `${hr}:${mu}:${ms}` + return `${mh}:${mu}:${ms}` } /** diff --git a/tests/content.spec.ts b/tests/content.spec.ts index 6751b392..197de45c 100644 --- a/tests/content.spec.ts +++ b/tests/content.spec.ts @@ -170,12 +170,12 @@ test('測試重新启动按鈕', async ({ content, tabUrl, context }) => { }) -test('測試打开监控式视窗按鈕', async ({ context, tabUrl, content }) => { +test('測試弹出直播视窗按鈕', async ({ context, tabUrl, content }) => { const settingsPage = await context.newPage() await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() - await settingsPage.getByText('启用监控视窗').click() + await settingsPage.getByText('启用弹出直播视窗').click() await settingsPage.getByText('保存设定').click() await settingsPage.close() @@ -183,19 +183,57 @@ test('測試打开监控式视窗按鈕', async ({ context, tabUrl, content }) = await content.locator('#bjf-main-menu').waitFor({ state: 'visible' }) const popup = context.waitForEvent('page', { predicate: p => p.url().includes('stream.html') }) - await content.getByText('打开监控式视窗').click() + await content.getByText('弹出直播视窗').click() const monitor = await popup await monitor.waitForTimeout(2000) - // danmaku container + // video container await expect(monitor.locator('div#__plasmo > div#bjf-danmaku-container')).toBeVisible() // video area await expect(monitor.locator('video#bjf-video')).toBeVisible() + // danmaku layer + await expect(monitor.locator('.N-dmLayer')).toBeVisible() + // media controller await expect(monitor.locator('media-controller#bjf-player')).toBeVisible() + // media controller bar + await expect(monitor.locator('media-control-bar')).toBeVisible() + + // media controller buttons + const buttons = [ + 'media-play-button', + 'media-live-button', + 'media-time-display', + 'media-mute-button', + 'media-volume-range', + 'media-chrome-button#danmaku-btn', // custom button + 'media-chrome-button#reload-btn' // custom button + ] + + await monitor.locator('media-control-bar').hover() + for (const button of buttons) { + const locator = monitor.locator(button) + await expect(locator).toBeVisible() + } + + // Test custom buttons + + // danmaku button + await monitor.locator('media-control-bar').hover() + await monitor.locator('#danmaku-btn').click() + await expect(monitor.locator('.N-dmLayer')).toHaveCSS('display', 'none') + await monitor.locator('#danmaku-btn').click() + await expect(monitor.locator('.N-dmLayer')).toHaveCSS('display', 'block') + + // reload button + await monitor.locator('media-control-bar').hover() + const reload = monitor.waitForEvent('load') + await monitor.locator('#reload-btn').click() + await reload + await monitor.close() }) @@ -315,10 +353,10 @@ test('測試导航', async ({ room, content, serviceWorker }) => { logger.info('正在測試導航前向...') - const next = content.getByRole('button', { name: '下一步' }) - const previous = content.getByRole('button', { name: '上一步' }) - const skip = content.getByRole('button', { name: '跳过' }) - const finish = content.getByRole('button', { name: '完成' }) + const next = content.locator('[data-test-id=button-primary]').filter({ hasText: '下一步' }) + const previous = content.locator('[data-test-id=button-back]') + const skip = content.locator('[data-test-id=button-skip]') + const finish = content.locator('[data-test-id=button-primary]').filter({ hasText: '完成' }) while (await next.isVisible()) { await next.click() diff --git a/tests/features/jimaku.spec.ts b/tests/features/jimaku.spec.ts index 0bdb0759..376f8520 100644 --- a/tests/features/jimaku.spec.ts +++ b/tests/features/jimaku.spec.ts @@ -2,6 +2,7 @@ import type { Locator } from '@playwright/test' import { expect, test } from '@tests/fixtures/content' import logger from '@tests/helpers/logger' import { isFrame, type PageFrame } from '@tests/helpers/page-frame' +import { testFeatureRoomList } from '@tests/utils/playwright' import { readText } from 'tests/utils/file' test.beforeEach(async ({ content: p }) => { @@ -82,7 +83,7 @@ test('測試彈出同傳視窗', async ({ room, context, tabUrl, page, content } await settingsPage.waitForTimeout(1000) await settingsPage.getByText('功能设定').click() - await settingsPage.getByText('启用同传弹幕彈出式视窗').click() + await settingsPage.getByText('启用同传弹幕弹出式视窗').click() await settingsPage.getByText('保存设定').click() await settingsPage.waitForTimeout(2000) @@ -172,37 +173,13 @@ test('測試離線記錄彈幕', async ({ room, content: p, context, tabUrl, pag expect(subtitleList.length).toBe(0) }) -test('測試房間名單列表(黑名單/白名單)', async ({ room, content, context, tabUrl }) => { - - const subtitleList = content.locator('#subtitle-list') - await expect(subtitleList).toBeVisible() - - const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) - await settingsPage.getByText('功能设定').click() - const roomInput = settingsPage.getByTestId('jimaku-whitelist-rooms-input') - const switcher = settingsPage.getByTestId('jimaku-whitelist-rooms').getByText('使用为黑名单') - await roomInput.fill(room.info.roomid.toString()) - await switcher.click() - await roomInput.press('Enter') - - await settingsPage.getByText('保存设定').click() - - await room.page.bringToFront() - await content.waitForTimeout(1000) - - await expect(subtitleList).toBeHidden() - - await settingsPage.bringToFront() - await switcher.click() - await settingsPage.getByText('保存设定').click() - - await room.page.bringToFront() - await content.waitForTimeout(1000) - - await expect(subtitleList).toBeVisible() - -}) +test('測試房間名單列表(黑名單/白名單)', + testFeatureRoomList( + 'jimaku', + expect, + (content) => content.locator('#subtitle-list') + ) +) test('测试添加同传用户名单/黑名单', async ({ content, context, tabUrl, room }) => { diff --git a/tests/features/recorder.spec.ts b/tests/features/recorder.spec.ts new file mode 100644 index 00000000..b7017d1b --- /dev/null +++ b/tests/features/recorder.spec.ts @@ -0,0 +1,424 @@ +import { test, expect } from "@tests/fixtures/content" +import logger from "@tests/helpers/logger" +import { readMovieInfo } from "@tests/utils/file" +import { testFeatureRoomList } from "@tests/utils/playwright" +import fs from 'fs/promises' + +test.beforeEach(async ({ content, context, tabUrl, isThemeRoom }) => { + + logger.info('正在整理 out 文件夾...') + await fs.rm('out', { recursive: true, force: true }) + await fs.mkdir('out', { recursive: true }) + + if (isThemeRoom) { + test.slow() + await content.getByText('挂接成功').waitFor({ + state: 'visible', + timeout: 60000 + }) + } + + + logger.info('正在啟用功能...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + await settingsPage.getByText('启用快速切片').click() + await settingsPage.getByText("保存设定").click() + await settingsPage.getByText("所有设定已经保存成功。").waitFor({ state: 'visible' }) + await settingsPage.close() +}) + + +test('測試功能元素是否存在', async ({ content }) => { + + const csui = content.locator('bjf-csui') + await csui.waitFor({ state: 'attached', timeout: 10000 }) + + await expect(csui.locator('section#bjf-feature-recorder')).toBeAttached() + +}) + +test('測試錄製按鈕有否根據設定顯示', async ({ content, context, tabUrl }) => { + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + await expect(button).toBeVisible() + await expect(timer).toBeVisible() + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + await settingsPage.getByText('隐藏录制按钮').click() // hide the ui + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + await content.waitForTimeout(3000) + + await expect(button).toBeHidden() + await expect(timer).toBeHidden() +}) + +test('測試 timer 有否更新', async ({ content }) => { + + const timer = content.getByTestId('record-timer') + + const currentText = await timer.textContent() + logger.info('当前时间戳:', currentText) + + await content.waitForTimeout(3000) + + const newText = await timer.textContent() + logger.info('新的时间戳:', newText) + + expect(newText).not.toEqual(currentText) +}) + +test('測試房間名單列表(黑名單/白名單)', + testFeatureRoomList( + 'recorder', + expect, + (content) => content.getByTestId('record-button') + ) +) + +test('測試錄製 FLV', async ({ content, page, context, tabUrl }) => { + + test.slow() + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await settingsPage.getByTestId('record-output-type').locator('div > div').nth(0).click() + await settingsPage.getByText('FLV').click() + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + logger.info('正在錄製...') + const button = content.getByTestId('record-button') + await content.waitForTimeout(30000) + + const download = page.waitForEvent('download') + await button.click() + await expect(content.getByText('视频下载成功。')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.flv')).toBeTruthy() + await downloaded.saveAs('out/recording.flv') + + const info = await readMovieInfo('out/recording.flv') + + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThan(30) +}) + +test('測試錄製 HLS', async ({ content, page }) => { + + test.slow() + + // 默認使用 MP4 + logger.info('正在錄製...') + const button = content.getByTestId('record-button') + await content.waitForTimeout(30000) + + const download = page.waitForEvent('download') + await button.click() + await expect(content.getByText('视频下载成功。')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + await downloaded.saveAs('out/recording.mp4') + + const info = await readMovieInfo('out/recording.mp4') + + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThan(30) +}) + +test('測試熱鍵錄製', async ({ page, tabUrl, context, content }) => { + + test.slow() + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + const input = settingsPage.getByTestId('record-hotkey') + await input.click() + await expect(input).toHaveValue('监听输入中...') + await input.press('Control+Shift+R') + await expect(input).toHaveValue('Ctrl+Shift+R') + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + logger.info('正在錄製...') + await page.waitForTimeout(30000) + + const downloading = page.waitForEvent('download') + await page.keyboard.press('Control+Shift+R') + + const downloaded = await downloading + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + await downloaded.saveAs('out/recording.mp4') + + const info = await readMovieInfo('out/recording.mp4') + + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThan(30) +}) + + +test('測試錄製時長', async ({ content, page }) => { + + // 10 mins: 6 mins recording + 4 mins operations + test.setTimeout(600000) + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + // default using 5 mins duration, so use 6 mins here + await timer.waitFor({ state: 'visible' }) + logger.info('正在錄製...') + await page.waitForTimeout(360000) + + // timer should be fixed on 5 mins + await expect(timer).toHaveText('00:05:00') + const download = page.waitForEvent('download') + await button.click() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + await downloaded.saveAs('out/recording.mp4') + + const info = await readMovieInfo('out/recording.mp4') + + logger.info('視頻信息:', info) + logger.info('相對時長:', info.relativeDuration()) + + // should be around 5 mins + // here we use ~10s + const gap = 10 + expect(info.relativeDuration()).toBeGreaterThanOrEqual(300 - gap) + expect(info.relativeDuration()).toBeLessThanOrEqual(300 + gap) + +}) + + +test('測試手動錄製', async ({ content, page, context, tabUrl }) => { + + test.slow() + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await settingsPage.getByTestId('record-duration').locator('div > div').nth(0).click() + await settingsPage.getByText('手动录制').click() + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + // 非錄製狀態 + await expect(timer).toBeHidden() + + await button.click() + await expect(content.getByText('开始录制...')).toBeVisible() + await expect(timer).toBeVisible() + + await content.waitForTimeout(15000) + + const download = page.waitForEvent('download') + await button.click() + await expect(content.getByText('录制已中止。')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + await downloaded.saveAs('out/recording.mp4') + + const info = await readMovieInfo('out/recording.mp4') + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThanOrEqual(15) + +}) + + +test('測試 HLS 完整編譯', async ({ content, page, context, tabUrl }) => { + + // I bet 10 mins for this + test.setTimeout(600000) + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + await settingsPage.getByTestId('record-fix').locator('div > div').nth(0).click() + await settingsPage.getByText('完整编译').click() + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + await timer.waitFor({ state: 'visible' }) + logger.info('正在錄製...') + + await page.waitForTimeout(10000) + + const encoderPage = context.waitForEvent('page') + await button.click() + const encoder = await encoderPage + encoder.on('console', msg => logger.info('[encoder.html]', msg.text())) + + const frontend = (async () => { + await expect(content.getByText('准备视频中...')).toBeVisible() + await expect(content.getByText('视频已发送到后台进行完整编码')).toBeVisible({ + timeout: 30000 // longer wait + }) + })(); + + const backend = (async () => { + await expect(encoder.getByText('正在加载 FFMpeg')).toBeVisible() + await expect(encoder.getByText('FFMpeg 已成功加载。')).toBeVisible() + + try { + await expect(encoder.getByText('正在等待视频数据')).toBeVisible() + } catch { + logger.warn('由於檔案過小,獲取視頻數據飛快,快到看不見 ._.') + } + + })(); + + + await Promise.all([frontend, backend]) + await encoder.bringToFront() + + try { + await expect(encoder.getByText('修复视频中')).toBeVisible() + await expect(encoder.getByText('视频已修复完成。')).toBeVisible() + } catch { + logger.warn('由於檔案過小,修復視頻飛快,快到看不見 ._.') + } + + await expect(encoder.getByText('编译视频中')).toBeVisible() + + const downloaded = await encoder.waitForEvent('download', { + timeout: 600000 + }) + + await expect(encoder.getByText('视频已编译完成。')).toBeVisible() + expect(encoder.getByText(downloaded.suggestedFilename())).toBeVisible() + expect(() => downloaded.suggestedFilename().endsWith('.mp4')).toBeTruthy() + + await downloaded.saveAs('out/recording.mp4') + const info = await readMovieInfo('out/recording.mp4') + + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThanOrEqual(10) +}) + + +test('測試 FLV 完整編譯', async ({ content, page, context, tabUrl }) => { + + // I bet 10 mins for this + test.setTimeout(600000) + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + // change to flv first + await settingsPage.getByTestId('record-output-type').locator('div > div').nth(0).click() + await settingsPage.getByText('FLV').click() + + await settingsPage.getByTestId('record-fix').locator('div > div').nth(0).click() + await settingsPage.getByText('完整编译').click() + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + + await timer.waitFor({ state: 'visible' }) + logger.info('正在錄製...') + + await page.waitForTimeout(10000) + + const encoderPage = context.waitForEvent('page') + await button.click() + const encoder = await encoderPage + encoder.on('console', msg => logger.info('[encoder.html]', msg.text())) + + const frontend = (async () => { + await expect(content.getByText('准备视频中...')).toBeVisible() + await expect(content.getByText('视频已发送到后台进行完整编码')).toBeVisible({ + timeout: 30000 // longer wait + }) + })(); + + const backend = (async () => { + await expect(encoder.getByText('正在加载 FFMpeg')).toBeVisible() + await expect(encoder.getByText('FFMpeg 已成功加载。')).toBeVisible() + + try { + await expect(encoder.getByText('正在等待视频数据')).toBeVisible() + } catch { + logger.warn('由於檔案過小,獲取視頻數據飛快,快到看不見 ._.') + } + + })(); + + + await Promise.all([frontend, backend]) + await encoder.bringToFront() + + try { + await expect(encoder.getByText('修复视频中')).toBeVisible() + await expect(encoder.getByText('视频已修复完成。')).toBeVisible() + } catch { + logger.warn('由於檔案過小,修復視頻飛快,快到看不見 ._.') + } + + await expect(encoder.getByText('编译视频中')).toBeVisible() + + const downloaded = await encoder.waitForEvent('download', { + timeout: 600000 + }) + + await expect(encoder.getByText('视频已编译完成。')).toBeVisible() + expect(encoder.getByText(downloaded.suggestedFilename())).toBeVisible() + expect(() => downloaded.suggestedFilename().endsWith('.flv')).toBeTruthy() + + await downloaded.saveAs('out/recording.flv') + const info = await readMovieInfo('out/recording.flv') + + logger.info('視頻信息:', info) + + expect(info.relativeDuration()).toBeGreaterThanOrEqual(10) +}) \ No newline at end of file diff --git a/tests/features/superchat.spec.ts b/tests/features/superchat.spec.ts index 771c185c..48856808 100644 --- a/tests/features/superchat.spec.ts +++ b/tests/features/superchat.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@tests/fixtures/content' import logger from '@tests/helpers/logger' import { readText } from '@tests/utils/file' -import { getSuperChatList } from '@tests/utils/playwright' +import { getSuperChatList, testFeatureRoomList } from '@tests/utils/playwright' test.beforeEach(async ({ page }) => { logger.info('正在等待登入彈窗消失...') @@ -153,37 +153,13 @@ test('測試離線記錄醒目留言', async ({ room, content: p, context, tabUr expect(superchatList.length).toBe(0) }) -test('測試房間名單列表(黑名單/白名單)', async ({ room, content, context, tabUrl }) => { - - const superchatButton = content.locator('button', { hasText: /^醒目留言$/ }) - await expect(superchatButton).toBeVisible() - - const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) - await settingsPage.getByText('功能设定').click() - const roomInput = settingsPage.getByTestId('superchat-whitelist-rooms-input') - const switcher = settingsPage.getByTestId('superchat-whitelist-rooms').getByText('使用为黑名单') - await roomInput.fill(room.info.roomid.toString()) - await switcher.click() - await roomInput.press('Enter') - - await settingsPage.getByText('保存设定').click() - - await room.page.bringToFront() - await content.waitForTimeout(1000) - - await expect(superchatButton).toBeHidden() - - await settingsPage.bringToFront() - await switcher.click() - await settingsPage.getByText('保存设定').click() - - await room.page.bringToFront() - await content.waitForTimeout(1000) - - await expect(superchatButton).toBeVisible() - -}) +test('測試房間名單列表(黑名單/白名單)', + testFeatureRoomList( + 'superchat', + expect, + (content) => content.locator('button', { hasText: /^醒目留言$/ }) + ) +) test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ content, context, tabUrl }) => { diff --git a/tests/fixtures/background.ts b/tests/fixtures/background.ts index b7250006..7d1097a2 100644 --- a/tests/fixtures/background.ts +++ b/tests/fixtures/background.ts @@ -1,25 +1,19 @@ import type { Page, Worker } from "@playwright/test"; import BilibiliPage from "@tests/helpers/bilibili-page"; import { Strategy } from "@tests/utils/misc"; -import { extensionBase } from "./base"; +import { extensionBase } from "./extension"; export type BackgroundOptions = { } export type BackgroundFixtures = { - settings: Page front: BilibiliPage serviceWorker: Worker } export const test = extensionBase.extend({ - // 代表 设定页面 - settings: async ({ page, tabUrl }, use) => { - await page.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) - await use(page) - }, // 直播间页面用 front: async ({ context, rooms, api, isThemeRoom, cacher }, use) => { const frontPage = await context.newPage() diff --git a/tests/fixtures/base.ts b/tests/fixtures/base.ts index 3759bea0..f6fb08e5 100644 --- a/tests/fixtures/base.ts +++ b/tests/fixtures/base.ts @@ -1,78 +1,27 @@ -import { test as base, chromium, expect, type BrowserContext, type Worker } from '@playwright/test' +import { expect, test as pwBase } from '@playwright/test' import BilbiliApi, { type LiveRoomInfo } from '@tests/helpers/bilibili-api' -import logger from '@tests/helpers/logger' -import RoomTypeFinder from '@tests/helpers/room-finder' -import path from 'path' -export type ExtensionOptions = { +export type BaseOptions = { maxPage: number roomId: number - maxRoomRetries: number - isThemeRoom: boolean } -export type ExtensionFixture = { - context: BrowserContext - extensionId: string - tabUrl: (tab: string) => string - serviceWorker: Worker -} - -export type ExtensionWorkerFixture = { +export type BaseWorkerFixtures = { api: BilbiliApi rooms: LiveRoomInfo[] - cacher: RoomTypeFinder } -export const extensionBase = base.extend({ +export const base = pwBase.extend<{}, BaseOptions & BaseWorkerFixtures>({ maxPage: [5, { option: true, scope: 'worker' }], roomId: [-1, { option: true, scope: 'worker' }], - maxRoomRetries: [70, { option: true, scope: 'worker' }], - isThemeRoom: [false, { option: true, scope: 'worker' }], - cacher: [ - async ({ browser, api, isThemeRoom }, use) => { - const cacher = new RoomTypeFinder(browser, api) - if (isThemeRoom) { - const info = await cacher.loadFromFileCache('theme') - if (info === 'none') { - console.warn(`從緩存找不到大海報的房間, 跳過所有大海報房間測試...`) - extensionBase.skip() - } else if (info !== 'null') { - console.info(`已成功從緩存找到大海報房間: ${info.roomid}`) - } - // if null, just re-search - } - await use(cacher) - }, - { scope: 'worker', timeout: 0 } - ], - - context: async ({ }, use) => { - const pathToExtension = path.join(__dirname, '../../build/extension') - const context = await chromium.launchPersistentContext('', { - headless: false, - args: [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - ...(process.env.CI ? ['--headless=new'] : []) - ], - }); - await use(context); - await context.close(); - }, - extensionId: async ({ serviceWorker }, use) => { - const extensionId = serviceWorker.url().split('/')[2] - logger.info(`using extension id: ${extensionId}`) - await use(extensionId); - }, rooms: [ async ({ maxPage, roomId, api }, use) => { const rooms = roomId > 0 ? [await api.findLiveRoom(roomId)] : await api.getLiveRoomsRange(maxPage) expect(rooms, 'rooms is not undefined').toBeDefined() expect(rooms.length, 'live rooms more than 1').toBeGreaterThan(0) - extensionBase.skip(rooms[0] === null, 'failed to fetch bilibili live room') + pwBase.skip(rooms[0] === null, 'failed to fetch bilibili live room') await use(rooms) }, { scope: 'worker' } @@ -84,35 +33,5 @@ export const extensionBase = base.extend { - await use((tab: string) => `chrome-extension://${extensionId}/tabs/${tab}`) - }, - serviceWorker: [ - async ({ context }, use) => { - logger.info('total service workers: ', context.serviceWorkers().length) - let [background] = context.serviceWorkers() - if (!background) { - logger.info('cannot find background, waiting for service worker') - background = await context.waitForEvent('serviceworker', { - predicate: sw => sw.url().includes('chrome-extension') - }) - logger.info('found service worker: ', background.url()) - } - - // 預先關閉自動用戶導航 - const page = await context.newPage() - await page.goto(background.url()) - await page.evaluate(async () => { - await chrome.storage.local.set({ - 'no_auto_journal.settings': true, - 'no_auto_journal.content': true, - }) - }) - logger.info('已關閉自動用戶導航') - await page.close() - - await use(background) - }, - { auto: true } - ] + }) \ No newline at end of file diff --git a/tests/fixtures/component.ts b/tests/fixtures/component.ts new file mode 100644 index 00000000..a679591d --- /dev/null +++ b/tests/fixtures/component.ts @@ -0,0 +1,90 @@ +import { getJSFiles } from "@tests/utils/file" +import * as esbuild from "esbuild" +import { base } from "./base" +import logger from "@tests/helpers/logger" +import type { StreamUrls } from "~background/messages/get-stream-urls" +import { Strategy } from "@tests/utils/misc" +import type { LiveRoomInfo } from "@tests/helpers/bilibili-api" +import fs from 'fs/promises' + +export type IntegrationFixtures = { + modules: Record + room: LiveRoomInfo & { stream: StreamUrls } + clearOutDir(): Promise +} + +export type FileModule = { + path: string + code: string + loadToPage(): Promise +} + +export const test = base.extend({ + + modules: [ + async ({ page }, use) => { + const results = await esbuild.build({ + bundle: true, + outdir: 'out/modules', + entryPoints: getJSFiles('tests/modules'), + write: false, + }) + const modules = Object.fromEntries( + results.outputFiles.map(file => [ + file.path.split(/[\\\/]/).pop().split('.')[0], + ({ + path: file.path, + code: file.text, + loadToPage: async () => { + await page.addScriptTag({ content: file.text, type: 'module' }) + } + }) + ]) + ) + logger.info(`loaded ${Object.keys(modules).length} modules`) + await use(modules) + }, + { timeout: 0 } + ], + + page: async ({ page }, use) => { + await page.goto('https://www.google.com/') + await use(page) + await page.close() + }, + + room: async ({ api, rooms }, use) => { + logger.debug(`selecting from ${rooms.length} rooms`) + const iterator = Strategy.random(rooms) + let stream: StreamUrls + let selected: LiveRoomInfo + for (const room of iterator) { + try { + logger.info(`尝试获取房间 ${room.roomid} 的流`) + stream = await api.getStreamUrls(room.roomid) + if (stream.length === 0) continue + selected = room + break + } catch (err: any) { + logger.warn(`获取房间 ${room.roomid} 的流失败: ${err?.message}`) + logger.info(`尝试下一个房间`) + } + } + test.skip(!selected || !stream || stream.length === 0, '无法获取直播流') + await use({ stream, ...selected }) + } +}) + +export const expect = test.expect + + +test.beforeEach(async ({ context }) => { + await fs.rm('out', { recursive: true, force: true }) + await fs.mkdir('out', { recursive: true }) + context.on('console', logger.debug) +}) + +test.afterEach(async ({ page, context }) => { + await page.close() + await context.close() +}) \ No newline at end of file diff --git a/tests/fixtures/content.ts b/tests/fixtures/content.ts index 5c3b4ebd..994158f0 100644 --- a/tests/fixtures/content.ts +++ b/tests/fixtures/content.ts @@ -2,7 +2,8 @@ import BilibiliPage from "@tests/helpers/bilibili-page"; import logger from "@tests/helpers/logger"; import type { PageFrame } from "@tests/helpers/page-frame"; import { Strategy } from "@tests/utils/misc"; -import { extensionBase } from "./base"; +import { extensionBase } from "./extension"; +import type { Locator } from "@playwright/test"; export type ContentOptions = { } @@ -61,4 +62,11 @@ export const test = extensionBase.extend({ }) -export const expect = test.expect \ No newline at end of file +export const expect = test.expect + +test.beforeEach(async ({ context }) => { + context.on('console', (msg) => { + if (!msg.text().includes('bilibili-vup-stream-enhancer')) return + logger.debug(msg.text().replace('bilibili-vup-stream-enhancer', 'console')) + }) +}) \ No newline at end of file diff --git a/tests/fixtures/extension.ts b/tests/fixtures/extension.ts new file mode 100644 index 00000000..75b0a7e0 --- /dev/null +++ b/tests/fixtures/extension.ts @@ -0,0 +1,95 @@ +import { chromium, type BrowserContext, type Worker } from '@playwright/test' +import logger from '@tests/helpers/logger' +import RoomTypeFinder from '@tests/helpers/room-finder' +import path from 'path' +import { base } from './base' + +export type ExtensionOptions = { + maxRoomRetries: number + isThemeRoom: boolean +} + +export type ExtensionFixtures = { + context: BrowserContext + extensionId: string + tabUrl: (tab: string) => string + serviceWorker: Worker +} + +export type ExtensionWorkerFixtures = { + cacher: RoomTypeFinder +} + +export const extensionBase = base.extend({ + + maxRoomRetries: [70, { option: true, scope: 'worker' }], + isThemeRoom: [false, { option: true, scope: 'worker' }], + + cacher: [ + async ({ browser, api, isThemeRoom }, use) => { + const cacher = new RoomTypeFinder(browser, api) + if (isThemeRoom) { + const info = await cacher.loadFromFileCache('theme') + if (info === 'none') { + console.warn(`從緩存找不到大海報的房間, 跳過所有大海報房間測試...`) + extensionBase.skip() + } else if (info !== 'null') { + console.info(`已成功從緩存找到大海報房間: ${info.roomid}`) + } + // if null, just re-search + } + await use(cacher) + }, + { scope: 'worker', timeout: 0 } + ], + + context: async ({ }, use) => { + const pathToExtension = path.join(__dirname, '../../build/extension') + const context = await chromium.launchPersistentContext('', { + headless: false, + args: [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ...(process.env.CI ? ['--headless=new'] : []) + ], + }); + await use(context); + await context.close(); + }, + extensionId: async ({ serviceWorker }, use) => { + const extensionId = serviceWorker.url().split('/')[2] + logger.info(`using extension id: ${extensionId}`) + await use(extensionId); + }, + tabUrl: async ({ extensionId }, use) => { + await use((tab: string) => `chrome-extension://${extensionId}/tabs/${tab}`) + }, + serviceWorker: [ + async ({ context }, use) => { + logger.info('total service workers: ', context.serviceWorkers().length) + let [background] = context.serviceWorkers() + if (!background) { + logger.info('cannot find background, waiting for service worker') + background = await context.waitForEvent('serviceworker', { + predicate: sw => sw.url().includes('chrome-extension') + }) + logger.info('found service worker: ', background.url()) + } + + // 預先關閉自動用戶導航 + const page = await context.newPage() + await page.goto(background.url()) + await page.evaluate(async () => { + await chrome.storage.local.set({ + 'no_auto_journal.settings': true, + 'no_auto_journal.content': true, + }) + }) + logger.info('已關閉自動用戶導航') + await page.close() + + await use(background) + }, + { auto: true } + ] +}) \ No newline at end of file diff --git a/tests/helpers/bilibili-api.ts b/tests/helpers/bilibili-api.ts index 27b57859..2466dd67 100644 --- a/tests/helpers/bilibili-api.ts +++ b/tests/helpers/bilibili-api.ts @@ -1,4 +1,8 @@ import { request, type APIRequestContext } from "@playwright/test"; +import { sendInternal } from "~background/messages"; +import type { StreamUrls } from "~background/messages/get-stream-urls"; +import type { V1Response, StreamUrlResponse } from "~types/bilibili"; +import logger from "./logger"; export interface LiveRoomInfo { roomid: number; @@ -43,7 +47,7 @@ export default class BilbiliApi { * @returns 一个解析为获取的数据的Promise。 * @throws 如果获取操作失败,则抛出错误。 */ - private async fetch(path: string): Promise { + private async fetch(path: string): Promise { const res = await this.context.get(path) if (!res.ok()) throw new Error(`获取bilibili API失败:${res.statusText()}`) return await res.json() @@ -112,4 +116,60 @@ export default class BilbiliApi { return data.data.list as LiveRoomInfo[] } + + /** + * Retrieves the stream URLs for a given room. + * @param room - The room ID or room number. + * @returns A promise that resolves to an array of stream URLs. + * @throws An error if the room is hidden, locked, or encrypted without verification. + * @throws An error if there are no available stream URLs. + */ + async getStreamUrls(room: number | string): Promise { + const res = await this.fetch>(`/xlive/web-room/v2/index/getRoomPlayInfo?room_id=${room}&protocol=0,1&format=0,2&codec=0,1&qn=10000&platform=web&ptype=16`) + if (res.code !== 0) throw new Error(res.message) + const data = res.data + + if (data.is_hidden) { + throw new Error('此直播間被隱藏') + } + + if (data.is_locked) { + throw new Error('此直播間已被封鎖') + } + + if (data.encrypted && !data.pwd_verified) { + throw new Error('此直播間已被上鎖') + } + + const streams = data?.playurl_info?.playurl?.stream ?? [] + const names = data?.playurl_info?.playurl?.g_qn_desc ?? [] + + logger.debug('stream urls:', JSON.stringify(streams, null, 2)) + + return streams + .filter(st => ['http_stream', 'http_hls'].includes(st.protocol_name)) + .flatMap(st => + st.format + .filter(format => st.protocol_name === 'http_hls' || format.format_name === 'flv') + .flatMap(format => { + return format.codec + .toSorted((a, b) => b.current_qn - a.current_qn) + .flatMap(codec => + codec.url_info.map(url_info => { + const queries = new URLSearchParams(url_info.extra) + const order = queries.get('order') ?? '0' + return ({ + desc: `${names.find(n => n.qn === codec.current_qn)?.desc ?? codec.current_qn}`, + url: url_info.host + codec.base_url + url_info.extra, + type: format.format_name === 'flv' ? 'flv' : 'hls', + codec: codec.codec_name, + track: order, + quality: codec.current_qn + }) + }) + ) + }) + ) + } + } \ No newline at end of file diff --git a/tests/integrations/player.spec.ts b/tests/integrations/player.spec.ts new file mode 100644 index 00000000..3554edaf --- /dev/null +++ b/tests/integrations/player.spec.ts @@ -0,0 +1,164 @@ +import { expect, test } from "@tests/fixtures/component"; +import logger from "@tests/helpers/logger"; +import { readMovieInfo } from "@tests/utils/file"; + +// because they both use single-threaded, so -c copy is needed for boosting time + +test( + '測試透過 Buffer 錄製 HLS 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', + async ({ room: { stream }, page, modules }) => { + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async ({ stream }) => { + + const { player, utils } = window as any + + const chunks = [] + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + const blob = new Blob([buffer], { type: 'application/octet-stream' }) + chunks.push(blob) + console.info('total chunks size: ', chunks.length, buffer.byteLength) + }, { + type: 'hls', + codec: 'avc' + }) + + await utils.misc.sleep(15000) + + console.info('cleaning up stream buffer...') + p.stopAndDestroy() + + utils.file.download('test.mp4', [...chunks], 'video/mp4') + + { + // for next use + (window as any).testVideo = chunks; + } + + return chunks.length + + }, { stream }) + + expect(length).toBeGreaterThanOrEqual(15) + const downloaded = await downloading + await downloaded.saveAs('out/test.mp4') + const info = await readMovieInfo('out/test.mp4') + + logger.info('info: ', info) + + expect(info.relativeDuration() || 0).toBe(0) // broken info + + // now trying to fix broken info with ffmpeg + + await modules['ffmpeg'].loadToPage() + + const downloadingFix = page.waitForEvent('download') + await page.evaluate(async () => { + const { testVideo, getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + console.log('ffmpeg loaded. converting file....') + const input = await new Blob(testVideo, { type: 'video/mp4' }).arrayBuffer() + await ffmpeg.writeFile('input.mp4', new Uint8Array(input)) + console.log('input file written, executing....') + await ffmpeg.exec(['-i', 'input.mp4', '-c', 'copy', 'output-uncut.mp4']) + await ffmpeg.exec(['-sseof', '-15', '-i', 'output-uncut.mp4', '-c', 'copy', 'output.mp4']) + console.log('output file written, downloading....') + const data = await ffmpeg.readFile('output.mp4') + const output = new Blob([data], { type: 'video/mp4' }) + utils.file.downloadBlob(output, 'fixed.mp4') + }) + + const downloadedFix = await downloadingFix + await downloadedFix.saveAs('out/fixed.mp4') + const infoFix = await readMovieInfo('out/fixed.mp4') + + logger.info('infoFix: ', infoFix) + logger.info('duration:', infoFix.relativeDuration()) + + expect(Math.round(infoFix.relativeDuration())).toBe(15) // fixed info + } +) + + +test( + '測試透過 Buffer 錄製 FLV 推流並用 ffmpeg.wasm 修復資訊損壞 + 剪時', + async ({ room: { stream }, page, modules }) => { + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async ({ stream }) => { + + const { player, utils } = window as any + + const chunks = [] + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + const blob = new Blob([buffer], { type: 'application/octet-stream' }) + chunks.push(blob) + console.info('total chunks size: ', chunks.length, buffer.byteLength) + }, { + type: 'flv', + codec: 'avc' + }) + + await utils.misc.sleep(15000) + + console.info('cleaning up stream buffer...') + p.stopAndDestroy() + + utils.file.download('test.flv', [...chunks], 'video/x-flv') + + { + // for next use + (window as any).testVideo = chunks; + } + + return chunks.length + + }, { stream }) + + expect(length).toBeGreaterThanOrEqual(15) + const downloaded = await downloading + await downloaded.saveAs('out/test.flv') + const info = await readMovieInfo('out/test.flv') + + logger.info('info: ', info) + + expect(info.relativeDuration() || 0).toBe(0) // broken info + + // now trying to fix broken info with ffmpeg + + await modules['ffmpeg'].loadToPage() + + const downloadingFix = page.waitForEvent('download') + await page.evaluate(async () => { + const { testVideo, getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + console.log('ffmpeg loaded. converting file....') + const input = await new Blob(testVideo, { type: 'video/x-flv' }).arrayBuffer() + await ffmpeg.writeFile('input.flv', new Uint8Array(input)) + console.log('input file written, executing....') + await ffmpeg.exec(['-i', 'input.flv', '-c', 'copy', 'output-uncut.flv']) + await ffmpeg.exec(['-sseof', '-15', '-i', 'output-uncut.flv', '-c', 'copy', 'output.flv']) + console.log('output file written, downloading....') + const data = await ffmpeg.readFile('output.flv') + const output = new Blob([data], { type: 'video/x-flv' }) + utils.file.downloadBlob(output, 'fixed.flv') + }) + + const downloadedFix = await downloadingFix + await downloadedFix.saveAs('out/fixed.flv') + const infoFix = await readMovieInfo('out/fixed.flv') + + logger.info('infoFix: ', infoFix) + logger.info('duration:', infoFix.relativeDuration()) + + expect(Math.round(infoFix.relativeDuration())).toBeGreaterThanOrEqual(15) // fixed info, but using copy will not cut the time precisely + + } + +) diff --git a/tests/modules/ffmpeg.js b/tests/modules/ffmpeg.js new file mode 100644 index 00000000..62a373f3 --- /dev/null +++ b/tests/modules/ffmpeg.js @@ -0,0 +1,22 @@ +import { FFmpeg } from "@ffmpeg/ffmpeg" +import { toBlobURL } from "@ffmpeg/util" + +const ffmpeg = new FFmpeg() +const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm" + +window.getFFmpeg = async function(){ + if (!ffmpeg.loaded) { + console.info('loading ffmpeg for first time...') + await ffmpeg.load({ + coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "application/javascript"), + wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"), + classWorkerURL: await toBlobURL('https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/umd/814.ffmpeg.js', "application/javascript") + }) + console.info('ffmpeg loaded!') + ffmpeg.on("log", ({ type, message }) => console.log(`[${type}] ${message}`)) + ffmpeg.on("progress", ({ progress, time }) => { + console.log(`progressing: ${progress * 100} % (transcoded time: ${time / 1000000} s)`) + }) + } + return ffmpeg +} \ No newline at end of file diff --git a/tests/modules/player.js b/tests/modules/player.js new file mode 100644 index 00000000..bd01a988 --- /dev/null +++ b/tests/modules/player.js @@ -0,0 +1,3 @@ +import loadStream, { recordStream } from '~players' + +window.player = { loadStream, recordStream } \ No newline at end of file diff --git a/tests/modules/utils.js b/tests/modules/utils.js new file mode 100644 index 00000000..c593af7f --- /dev/null +++ b/tests/modules/utils.js @@ -0,0 +1,5 @@ +import * as file from '~utils/file' +import * as misc from '~utils/misc' +import * as ffmpeg from '@ffmpeg/util' + +window.utils = { file, ffmpeg, misc } \ No newline at end of file diff --git a/tests/options.ts b/tests/options.ts index 5915a8f2..3888af23 100644 --- a/tests/options.ts +++ b/tests/options.ts @@ -1,8 +1,10 @@ import type { BackgroundOptions } from "./fixtures/background" -import type { ExtensionOptions } from "./fixtures/base" +import type { ExtensionOptions } from "./fixtures/extension" import type { ContentOptions } from "./fixtures/content" +import type { BaseOptions } from "./fixtures/base" export type GlobalOptions = ContentOptions & BackgroundOptions & - ExtensionOptions \ No newline at end of file + ExtensionOptions & + BaseOptions \ No newline at end of file diff --git a/tests/pages/encoder.spec.ts b/tests/pages/encoder.spec.ts new file mode 100644 index 00000000..6ac615be --- /dev/null +++ b/tests/pages/encoder.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@tests/fixtures/background"; + + + +test('測試加載多線程 ffmpeg.wasm ', async ({ page, tabUrl }) => { + await page.goto(tabUrl('encoder.html?id=12345'), { waitUntil: 'domcontentloaded' }) + await expect(page.getByText('正在加载 FFMpeg')).toBeVisible() + await expect(page.getByText('FFMpeg 已成功加载。')).toBeVisible() +}) + + +test('測試不帶ID時顯示無效的請求', async ({ page, tabUrl }) => { + await page.goto(tabUrl('encoder.html'), { waitUntil: 'domcontentloaded' }) + await expect(page.getByText('无效的请求')).toBeVisible() +}) \ No newline at end of file diff --git a/tests/pages/settings.spec.ts b/tests/pages/settings.spec.ts index a3ee9bde..57ce0ca9 100644 --- a/tests/pages/settings.spec.ts +++ b/tests/pages/settings.spec.ts @@ -6,11 +6,15 @@ import logger from '@tests/helpers/logger' import { getSuperChatList } from '@tests/utils/playwright' import type { MV2Settings } from '~migrations/schema' -test('測試頁面是否成功加載', async ({ settings: page }) => { +test.beforeEach(async ({ page, tabUrl }) => { + await page.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) +}) + +test('測試頁面是否成功加載', async ({ page }) => { await expect(page.getByText('设定页面')).toBeVisible() }) -test('測試所有設定區塊能否展開', async ({ settings: page }) => { +test('測試所有設定區塊能否展開', async ({ page }) => { const form = page.locator('form.container') await form.waitFor({ state: 'attached' }) expect(form).toBeDefined() @@ -21,7 +25,7 @@ test('測試所有設定區塊能否展開', async ({ settings: page }) => { } }) -test('測試能否保存設定', async ({ settings: page }) => { +test('測試能否保存設定', async ({ page }) => { logger.info('正在修改功能設定....') @@ -33,7 +37,7 @@ test('測試能否保存設定', async ({ settings: page }) => { const checkboxMonitor = page.getByTestId('monitor-window') await expect(checkboxMonitor).not.toBeChecked() - await page.getByText('启用监控视窗').click() + await page.getByText('启用弹出直播视窗').click() await page.getByText('字幕设定').click() @@ -84,7 +88,7 @@ test('測試能否保存設定', async ({ settings: page }) => { await expect(liveFullScreenClass).toHaveValue('liveFullScreenClass changed') }) -test('測試導出導入設定', async ({ settings: page }) => { +test('測試導出導入設定', async ({ page }) => { logger.info('正在導出設定....') const downloading = page.waitForEvent('download') await page.getByText('导出设定').click() @@ -101,7 +105,7 @@ test('測試導出導入設定', async ({ settings: page }) => { const checkboxMonitor = page.getByTestId('monitor-window') await expect(checkboxMonitor).not.toBeChecked() - await page.getByText('启用监控视窗').click() + await page.getByText('启用弹出直播视窗').click() const inputSubtitleSize = page.getByTestId('jimaku-size') await expect(inputSubtitleSize).toHaveValue('16') @@ -143,7 +147,7 @@ test('測試導出導入設定', async ({ settings: page }) => { }) -test('測試清空數據庫', async ({ settings: page, front: room, api }) => { +test('測試清空數據庫', async ({ page, front: room, api }) => { await page.bringToFront() const feature = page.getByText('功能设定') @@ -229,7 +233,7 @@ test('測試清空數據庫', async ({ settings: page, front: room, api }) => { }) -test('測試從遠端獲取開發者設定', async ({ settings: page }) => { +test('測試從遠端獲取開發者設定', async ({ page }) => { const api = await request.newContext() const response = await api.get('https://cdn.jsdelivr.net/gh/eric2788/bilibili-vup-stream-enhancer@web/cdn/developer_v2.json') @@ -284,7 +288,7 @@ test('測試從遠端獲取開發者設定', async ({ settings: page }) => { expect(storageStr).toBe(JSON.stringify(remote)) }) -test('測試設定數據從MV2遷移', async ({ serviceWorker, settings: page }) => { +test('測試設定數據從MV2遷移', async ({ serviceWorker, page }) => { logger.info('正在測試寫入 MV2 設定....') const mv2Settings = await serviceWorker.evaluate(async () => { @@ -419,7 +423,7 @@ test('測試設定數據從MV2遷移', async ({ serviceWorker, settings: page }) }) -test('測試导航', async ({ settings: page, serviceWorker }) => { +test('測試导航', async ({ page, serviceWorker }) => { const overlay = page.locator('.react-joyride__overlay') @@ -431,12 +435,12 @@ test('測試导航', async ({ settings: page, serviceWorker }) => { logger.info('正在測試導航前向...') - const next = page.getByText('下一步') - const previous = page.getByText('上一步') - const skip = page.getByText('跳过') - const finish = page.getByText('完成') + const next = page.locator('[data-test-id=button-primary]').filter({ hasText: '下一步' }) + const previous = page.locator('[data-test-id=button-back]') + const skip = page.locator('[data-test-id=button-skip]') + const finish = page.locator('[data-test-id=button-primary]').filter({ hasText: '完成' }) - while(await next.isVisible()) { + while (await next.isVisible()) { await next.click() await page.waitForTimeout(100) } diff --git a/tests/theme.setup.ts b/tests/theme.setup.ts index 148937fd..c138aca8 100644 --- a/tests/theme.setup.ts +++ b/tests/theme.setup.ts @@ -1,8 +1,9 @@ -import { extensionBase as setup } from "./fixtures/base"; +import { extensionBase as setup } from "./fixtures/extension"; import logger from "./helpers/logger"; import { Strategy } from "./utils/misc"; setup('預先搜索大海報房間', async ({ cacher, rooms, maxRoomRetries, roomId }) => { + setup.setTimeout(0) setup.skip(roomId > 0, '已指定直播房間,跳過搜索') setup.skip(rooms.length < 2, '房間數量不足,跳過搜索') console.info('正在搜索大海報房間...') diff --git a/tests/types/movie.ts b/tests/types/movie.ts new file mode 100644 index 00000000..7c7a93ef --- /dev/null +++ b/tests/types/movie.ts @@ -0,0 +1,45 @@ +export interface Movie { + duration: number; + timescale: number; + tracks: (VideoTrack | AudioTrack)[]; + + relativeDuration(): number + resolution(): string + size(): number + addTrack(track: VideoTrack | AudioTrack): void + videoTrack(): VideoTrack + audioTrack(): AudioTrack + samples(): number[] + ensureDuration(): number + +} + +export interface VideoTrack { + duration: number; + timescale: number; + extraData: Buffer; + codec: string; + samples: Sample[]; + width: number; + height: number; +} + +export interface AudioTrack { + duration: number; + timescale: number; + extraData: Buffer; + codec: string; + samples: Sample[]; + channels: number; + sampleRate: number; + sampleSize: number; +} + +export interface Sample { + timestamp: number + timescale: number + size: number + offset: number + + relativeTimestamp(): number +} \ No newline at end of file diff --git a/tests/helpers/bilibili-api.spec.ts b/tests/units/bilibili-api.spec.ts similarity index 80% rename from tests/helpers/bilibili-api.spec.ts rename to tests/units/bilibili-api.spec.ts index 7c7b6cab..77237a29 100644 --- a/tests/helpers/bilibili-api.spec.ts +++ b/tests/units/bilibili-api.spec.ts @@ -1,4 +1,4 @@ -import { extensionBase as test } from "@tests/fixtures/base"; +import { test } from "@tests/fixtures/component"; import logger from "@tests/helpers/logger"; test('getRoomStatus - API', async ({ api }) => { @@ -17,6 +17,10 @@ test('getLiveRooms - API', async ({ api }) => { await expectNoError(api.getLiveRooms()) }) +test('getStreamUrls - API', async ({ api }) => { + await expectNoError(api.getStreamUrls(21696950)) +}) + async function expectNoError(p: Promise) { try { const r = await p diff --git a/tests/units/buffer.spec.ts b/tests/units/buffer.spec.ts new file mode 100644 index 00000000..ec8279b1 --- /dev/null +++ b/tests/units/buffer.spec.ts @@ -0,0 +1,148 @@ +import { test, expect } from '@tests/fixtures/component' +import logger from '@tests/helpers/logger' +import { readMovieInfo } from '@tests/utils/file' + +test.slow() + +test('測試捕捉 HLS 推流', async ({ page, modules, room: { stream } }) => { + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async (stream) => { + + const { player, utils } = window as any + + const chunks = [] + + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + const blob = new Blob([buffer], { type: 'application/octet-stream' }) + chunks.push(blob) + console.info('total chunks size: ', chunks.length, buffer.byteLength) + }, { + type: 'hls', + codec: 'avc' + }) + + await utils.misc.sleep(15000) // 測試錄製 15 秒 + console.info('cleaning up stream buffer...') + p.stopAndDestroy() + + utils.file.download('test.mp4', [...chunks], 'video/mp4') + + return chunks.length + + }, stream) + + expect(length).toBeGreaterThan(0) // 確保有錄製到東西 + + const downloaded = await downloading + await downloaded.saveAs('out/test.mp4') + + const info = await readMovieInfo('out/test.mp4') // 確保影片是能被正常解析的 + logger.info('info: ', info) +}) + +test('測試捕捉 FLV 推流', async ({ page, modules, room: { stream } }) => { + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + const length = await page.evaluate(async (stream) => { + + const { player, utils } = window as any + + const chunks = [] + + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + const blob = new Blob([buffer], { type: 'application/octet-stream' }) + chunks.push(blob) + console.info('total chunks size: ', chunks.length, buffer.byteLength) + }, { + type: 'flv', + codec: 'avc' + }) + + await utils.misc.sleep(15000) // 測試錄製 15 秒 + console.info('cleaning up stream buffer...') + p.stopAndDestroy() + + utils.file.download('test.flv', [...chunks], 'video/flv') + + return chunks.length + + }, stream) + + expect(length).toBeGreaterThan(0) // 確保有錄製到東西 + + const downloaded = await downloading + await downloaded.saveAs('out/test.flv') + + const info = await readMovieInfo('out/test.flv') // 確保影片是能被正常解析的 + logger.info('info: ', info) +}) + +test('測試終止 HLS 推流后有否成功關閉數據流', async ({ modules, page, room: { stream } }) => { + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const { before, after } = await page.evaluate(async (stream) => { + + const { player, utils } = window as any + + let received = 0 + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + received++ + console.log('received: ', received) + }, { + type: 'hls', + codec: 'avc' + }) + + await utils.misc.sleep(5000) // 測試錄製 5 秒 + console.info('stopping stream...') + p.stopAndDestroy() + const before = received + await utils.misc.sleep(5000) // 等待關閉 + console.info('checking stream...') + const after = received + return { before, after } + }, stream) + + expect(before).toBe(after) + +}) + +test('測試終止 FLV 推流后有否成功關閉數據流', async ({ modules, page, room: { stream } }) => { + + await modules['player'].loadToPage() + await modules['utils'].loadToPage() + + const { before, after } = await page.evaluate(async (stream) => { + + const { player, utils } = window as any + + let received = 0 + const p = await player.recordStream(stream, (buffer: ArrayBuffer) => { + received++ + console.log('received: ', received) + }, { + type: 'flv', + codec: 'avc' + }) + + await utils.misc.sleep(5000) // 測試錄製 5 秒 + console.info('stopping stream...') + p.stopAndDestroy() + const before = received + await utils.misc.sleep(5000) // 等待關閉 + console.info('checking stream...') + const after = received + return { before, after } + }, stream) + + expect(before).toBe(after) +}) \ No newline at end of file diff --git a/tests/units/ffmpeg.spec.ts b/tests/units/ffmpeg.spec.ts new file mode 100644 index 00000000..203dd07d --- /dev/null +++ b/tests/units/ffmpeg.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from '@tests/fixtures/component' +import logger from '@tests/helpers/logger' +import { readGifInfo, readMovieInfo } from '@tests/utils/file' +import fs from 'fs/promises' + +test('測試在 playwright 加載 ffmpeg.wasm', async ({ page, modules }) => { + + await modules['ffmpeg'].loadToPage() + + const ret = await page.evaluate(async () => { + const { getFFmpeg } = window as any + const ffmpeg = await getFFmpeg() + return await ffmpeg.exec(['-version']) + }) + + expect(ret).toBe(0) +}) + +test('測試 ffmpeg.wasm 轉換 mp4 到 gif', async ({ context, page, modules }) => { + + await modules['ffmpeg'].loadToPage() + await modules['utils'].loadToPage() + + const downloading = page.waitForEvent('download') + + logger.info('downloading video...') + const res = await context.request.get('https://www.w3schools.com/html/mov_bbb.mp4') + test.skip(res.status() !== 200, '无法下载测试视频') + const mp4Content = await res.body() + + const info = await readMovieInfo(mp4Content) // ensure is mp4 + logger.info('mp4 info: ', info) + + expect(info.duration).toBeGreaterThan(0) + + await fs.writeFile('out/input.mp4', mp4Content, {}) + + const mp4 = atob(mp4Content.toString('base64')).split('').map(c => c.charCodeAt(0)) + await page.evaluate(async (mp4) => { + + const { getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + const inFile = 'input.mp4' + const outFile = 'out.gif' + + await ffmpeg.writeFile(inFile, new Uint8Array(mp4)) + await ffmpeg.exec(['-i', inFile, '-f', 'gif', outFile]) + + const data = await ffmpeg.readFile(outFile) + const output = new Blob([data], { type: 'image/gif' }) + utils.file.downloadBlob(output, outFile) + + }, mp4) + + const downloaded = await downloading + await downloaded.saveAs('out/test.gif') + + const infoGif = await readGifInfo('out/test.gif') // ensure is gif + logger.info('gif info: ', infoGif) + + expect(infoGif.valid).toBe(true) +}) + +// FYR: https://ffmpegwasm.netlify.app/docs/faq#is-rtsp-supported-by-ffmpegwasm +test.fail('測試透過 ffmpeg.wasm 錄製 http-flv 直播流', async ({ modules, room: { stream, roomid }, page }) => { + + await modules['utils'].loadToPage() + await modules['ffmpeg'].loadToPage() + + const flvs = stream.filter(s => s.type === 'flv').map(s => s.url) + test.skip(flvs.length === 0, '没有可用的直播视频流') + + const downloading = page.waitForEvent('download') + await page.evaluate(async ({ flvs, roomid }) => { + + const { getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + + const outFile = `${roomid}.flv` + await ffmpeg.exec(['-f', 'live_flv', '-i', flvs[0].replace('https', 'http'), outFile], 15 * 1000) + const data = await ffmpeg.readFile(outFile) + const output = new Blob([data], { type: 'video/mp4' }) + utils.file.downloadBlob(output, outFile) + + }, { flvs, roomid }) + + const downloaded = await downloading + await downloaded.saveAs('out/test.flv') + + const info = await readMovieInfo('out/test.flv') + + logger.info('info: ', info) + +}) + +// FYR: https://ffmpegwasm.netlify.app/docs/faq#is-rtsp-supported-by-ffmpegwasm +test.fail('測試透過 ffmpeg.wasm 錄製 HLS 直播流', async ({ modules, room: { stream, roomid }, page }) => { + + await modules['utils'].loadToPage() + await modules['ffmpeg'].loadToPage() + + const hls = stream.filter(s => s.type === 'hls').map(s => s.url) + test.skip(hls.length === 0, '没有可用的直播视频流') + + const downloading = page.waitForEvent('download') + await page.evaluate(async ({ hls, roomid }) => { + + const { getFFmpeg, utils } = window as any + const ffmpeg = await getFFmpeg() + + const outFile = `${roomid}.flv` + await ffmpeg.exec(['-f', 'live_flv', '-i', hls[0].replace('https', 'http'), outFile], 15 * 1000) + const data = await ffmpeg.readFile(outFile) + const output = new Blob([data], { type: 'video/mp4' }) + utils.file.downloadBlob(output, outFile) + + }, { hls, roomid }) + + const downloaded = await downloading + await downloaded.saveAs('out/test.mp4') + + const info = await readMovieInfo('out/test.mp4') + + logger.info('info: ', info) + +}) \ No newline at end of file diff --git a/tests/utils/file.ts b/tests/utils/file.ts index a6816562..aa11cc71 100644 --- a/tests/utils/file.ts +++ b/tests/utils/file.ts @@ -1,5 +1,11 @@ +/// import { glob, type GlobOptions as IOptions } from 'glob' import type { Readable } from 'stream' +import fs from 'fs/promises' +import type { PathLike } from 'fs' +import VideoLib from 'node-video-lib' +import gifyParse from 'gify-parse' +import type { Movie } from '@tests/types/movie' export type IModule = { name: string, @@ -8,13 +14,13 @@ export type IModule = { } /** - * Retrieves an array of TypeScript file paths from the specified directory path. - * @param dirPath - The directory path to search for TypeScript files. + * Retrieves an array of JavaScript/TypeScript file paths from the specified directory path. + * @param dirPath - The directory path to search for JavaScript/TypeScript files. * @param options - Optional configuration options for the glob pattern matching. - * @returns An array of TypeScript file paths. + * @returns An array of JavaScript/TypeScript file paths. */ -export function getTSFiles(dirPath: string, options?: IOptions): string[] { - return glob.sync(`${dirPath}/**/*.{ts,tsx}`, options) as string[] +export function getJSFiles(dirPath: string, options?: IOptions): string[] { + return glob.sync(`${dirPath}/**/*.{ts,tsx,js,jsx}`, options) as string[] } /** @@ -24,8 +30,8 @@ export function getTSFiles(dirPath: string, options?: IOptions): string[] { * @returns A generator that yields promises of modules. */ export function* getModuleStream(dirPath: string, options: IOptions = { ignore: '**/index.ts' }): Generator, void, any> { - for (const file of getTSFiles(dirPath, options)) { - const name = file.split('/').pop().split('.')[0] + for (const file of getJSFiles(dirPath, options)) { + const name = file.split(/[\\\/]/).pop().split('.')[0] yield import(file).then(module => ({ name, file, module })) } } @@ -35,8 +41,8 @@ export function* getModuleStream(dirPath: string, options: IOptions = { ignore: * @param dirPath - The directory path to search for TypeScript files. * @param options - The options for filtering files. Default value is { ignore: '**/ export function* getModuleStreamSync(dirPath: string, options: IOptions = { ignore: '**/index.ts' }): Generator { - for (const file of getTSFiles(dirPath, options)) { - const name = file.split('/').pop().split('.')[0] + for (const file of getJSFiles(dirPath, options)) { + const name = file.split(/[\\\/]/).pop().split('.')[0] yield { name, file, module: require(file) } } } @@ -53,4 +59,21 @@ export async function readText(readable: Readable): Promise { readable.on('end', () => res(data)) readable.on('error', rej) }) +} + + + +export async function readMovieInfo(source: PathLike | Buffer): Promise { + source = await readBufferIfNeeded(source) + return VideoLib.MovieParser.parse(source) +} + + +export async function readGifInfo(source: PathLike | Buffer) { + source = await readBufferIfNeeded(source) + return gifyParse.getInfo(source); +} + +async function readBufferIfNeeded(source: PathLike | Buffer): Promise { + return Buffer.isBuffer(source) ? source : fs.readFile(source) } \ No newline at end of file diff --git a/tests/utils/misc.ts b/tests/utils/misc.ts index 5640a292..d289e63f 100644 --- a/tests/utils/misc.ts +++ b/tests/utils/misc.ts @@ -1,5 +1,6 @@ + /** * Returns a random item from the given array. * @param items - The array of items. diff --git a/tests/utils/playwright.ts b/tests/utils/playwright.ts index e0b0db76..98a25ee2 100644 --- a/tests/utils/playwright.ts +++ b/tests/utils/playwright.ts @@ -1,4 +1,6 @@ -import type { Locator } from "@playwright/test" +import type { Expect, Locator } from "@playwright/test" +import type { ContentFixtures, ContentOptions } from "@tests/fixtures/content"; +import type { PageFrame } from "@tests/helpers/page-frame"; /** * Finds the first locator in the given array that satisfies the provided predicate. @@ -33,4 +35,59 @@ export async function getSuperChatList(section: Locator, options?: { await section.locator('button', { hasText: /^醒目留言$/ }).click() } return section.getByRole('menu').locator('section.bjf-scrollbar > div').filter(options).all() +} + +/** + * A utility function that tests a feature in a room list. + * + * @param feature - The feature to test. + * @param expect - The expect function from the testing framework. + * @param locate - A function that locates the content on the page. + * @returns A pre-configured test function that tests the feature in a room list. + * + * @example + * + * ```ts + * test('測試房間名單列表(黑名單/白名單)', + * testFeatureRoomList( + * 'jimaku', + * expect, + * (content) => content.locator('#subtitle-list') + * ) + * ) + * ``` + */ +export function testFeatureRoomList(feature: string, expect: Expect, locate: (content: PageFrame) => Locator): (args: any) => Promise { + + return async ({ room, content, context, tabUrl }) => { + + const locator = locate(content) + await expect(locator).toBeVisible() + + const settingsPage = await context.newPage() + await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + const roomInput = settingsPage.getByTestId(feature+'-whitelist-rooms-input') + const switcher = settingsPage.getByTestId(feature+'-whitelist-rooms').getByText('使用为黑名单') + await roomInput.fill(room.info.roomid.toString()) + await switcher.click() + await roomInput.press('Enter') + + await settingsPage.getByText('保存设定').click() + + await room.page.bringToFront() + await content.waitForTimeout(1000) + + await expect(locator).toBeHidden() + + await settingsPage.bringToFront() + await switcher.click() + await settingsPage.getByText('保存设定').click() + + await room.page.bringToFront() + await content.waitForTimeout(1000) + + await expect(locator).toBeVisible() + + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d9172896..3639568f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,6 @@ "./tests/*" ] }, - "baseUrl": ".", - } + "baseUrl": "." + }, } From 8ea7452c1e9ba1da1462917530c23ebf206168f7 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sat, 6 Apr 2024 20:16:51 +0800 Subject: [PATCH 07/14] added push event trigger workflow when contains [run ci] --- .github/workflows/build-test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index b48f12f7..5476918c 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -1,6 +1,12 @@ name: Build And Test Extensions on: + push: + branches: [develop] + paths: + - 'src/**' + - 'tests/**' + - 'playwright.config.ts' pull_request: branches: [develop, master] types: @@ -24,6 +30,7 @@ on: jobs: build: runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && contains(github.event.head_commit.message, '[run ci]')) }} strategy: fail-fast: false matrix: From 900b4d514a461d05cafb00aa4c2ffa1b1ac966b8 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sat, 6 Apr 2024 20:38:07 +0800 Subject: [PATCH 08/14] changed settings page location from `tabs/settings.html` to `options.html` to match mv3 standard --- CONTRIBUTING.md | 2 +- docs/features.md | 12 ++++---- docs/settings.md | 10 +++---- src/adapters/capture-element.ts | 2 +- src/background/index.ts | 2 +- src/background/messages.ts | 4 ++- src/background/messages/fetch-developer.ts | 2 +- src/background/messages/hook-adapter.ts | 2 +- src/background/messages/migration-mv2.ts | 2 +- src/background/messages/open-options.ts | 7 +++++ src/components/OfflineRecordsProvider.tsx | 2 +- src/contents/index/components/ButtonList.tsx | 2 +- src/contents/index/mounter.tsx | 4 +-- src/contexts/ContentContexts.ts | 2 +- src/contexts/JimakuFeatureContext.ts | 2 +- src/contexts/RecorderFeatureContext.ts | 2 +- src/contexts/SuperChatFeatureContext.ts | 2 +- src/features/index.ts | 2 +- src/features/jimaku/components/JimakuArea.tsx | 2 +- src/features/jimaku/components/JimakuList.tsx | 2 +- .../jimaku/components/JimakuVisibleButton.tsx | 2 +- src/hooks/bilibili.ts | 2 +- src/migrations/index.ts | 4 +-- src/migrations/schema.ts | 2 +- .../components/AffixInput.tsx | 0 .../components/CheckBoxListItem.tsx | 0 .../components/ColorInput.tsx | 0 .../components/DataTable.tsx | 0 .../components/DeleteIcon.tsx | 0 .../components/Expander.tsx | 0 .../components/ExperientmentFeatureIcon.tsx | 0 .../components/FeatureRoomTable.tsx | 0 .../components/Hints.tsx | 0 .../components/HotKeyInput.tsx | 0 .../components/Selector.tsx | 0 .../components/SettingFragment.tsx | 2 +- .../components/SwitchListItem.tsx | 0 src/{settings => options}/features/index.ts | 0 .../jimaku/components/ButtonFragment.tsx | 2 +- .../jimaku/components/DanmakuFragment.tsx | 8 +++--- .../jimaku/components/JimakuFragment.tsx | 10 +++---- .../jimaku/components/ListingFragment.tsx | 6 ++-- .../features/jimaku/index.tsx | 6 ++-- .../features/recorder/index.tsx | 6 ++-- .../features/superchat/index.tsx | 2 +- .../index.ts => options/fragments.ts} | 0 .../fragments/capture.tsx | 4 +-- .../fragments/developer.tsx | 0 .../fragments/display.tsx | 2 +- .../fragments/features.tsx | 10 +++---- .../fragments/listings.tsx | 4 +-- .../fragments/version.tsx | 2 +- src/{tabs/settings.tsx => options/index.tsx} | 4 +-- src/utils/bilibili.ts | 2 +- src/utils/storage.ts | 2 +- tests/content.spec.ts | 26 ++++++++--------- tests/features/jimaku.spec.ts | 16 +++++------ tests/features/recorder.spec.ts | 28 +++++++++---------- tests/features/superchat.spec.ts | 12 ++++---- tests/fixtures/extension.ts | 4 +++ .../{settings.spec.ts => options.spec.ts} | 4 +-- tests/utils/playwright.ts | 2 +- 62 files changed, 127 insertions(+), 114 deletions(-) create mode 100644 src/background/messages/open-options.ts rename src/{settings => options}/components/AffixInput.tsx (100%) rename src/{settings => options}/components/CheckBoxListItem.tsx (100%) rename src/{settings => options}/components/ColorInput.tsx (100%) rename src/{settings => options}/components/DataTable.tsx (100%) rename src/{settings => options}/components/DeleteIcon.tsx (100%) rename src/{settings => options}/components/Expander.tsx (100%) rename src/{settings => options}/components/ExperientmentFeatureIcon.tsx (100%) rename src/{settings => options}/components/FeatureRoomTable.tsx (100%) rename src/{settings => options}/components/Hints.tsx (100%) rename src/{settings => options}/components/HotKeyInput.tsx (100%) rename src/{settings => options}/components/Selector.tsx (100%) rename src/{settings => options}/components/SettingFragment.tsx (99%) rename src/{settings => options}/components/SwitchListItem.tsx (100%) rename src/{settings => options}/features/index.ts (100%) rename src/{settings => options}/features/jimaku/components/ButtonFragment.tsx (95%) rename src/{settings => options}/features/jimaku/components/DanmakuFragment.tsx (94%) rename src/{settings => options}/features/jimaku/components/JimakuFragment.tsx (95%) rename src/{settings => options}/features/jimaku/components/ListingFragment.tsx (96%) rename src/{settings => options}/features/jimaku/index.tsx (95%) rename src/{settings => options}/features/recorder/index.tsx (96%) rename src/{settings => options}/features/superchat/index.tsx (96%) rename src/{settings/index.ts => options/fragments.ts} (100%) rename src/{settings => options}/fragments/capture.tsx (95%) rename src/{settings => options}/fragments/developer.tsx (100%) rename src/{settings => options}/fragments/display.tsx (97%) rename src/{settings => options}/fragments/features.tsx (96%) rename src/{settings => options}/fragments/listings.tsx (96%) rename src/{settings => options}/fragments/version.tsx (98%) rename src/{tabs/settings.tsx => options/index.tsx} (99%) rename tests/pages/{settings.spec.ts => options.spec.ts} (99%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89cb9c63..4a19c09d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ src/ ├── hooks/ # 全局用的自定义 React Hooks。 ├── migrations/ # 设定迁移脚本(从MV2到MV3)。 ├── players/ # 直播解析器相关代码。 -├── settings/ # 设定库相关代码,包括对设定区块和功能设定区块的定义。 +├── options/ # 设定库相关代码,包括对设定区块和功能设定区块的定义。 ├── tabs/ # 浏览器扩展页面。 ├── types/ # 类型定义文件。 ├── utils/ # 实用工具函数。 diff --git a/docs/features.md b/docs/features.md index 6425a8ef..52aa28a0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,13 +7,13 @@ ``` src/ features/ <- 內容腳本 - settings/ + options/ features/ <- 功能设定区块 ``` `src/features/`: 此目录用于存放功能,虽然主要集中在内容脚本的渲染,但是也是定义结构的地方,因此必须添加。 -`src/settings/features/`: 此目录用于存放功能的设定区块,为了让用户能够切换功能的开关,此处也必须添加。 +`src/options/features/`: 此目录用于存放功能的设定区块,为了让用户能够切换功能的开关,此处也必须添加。 ## 新增内容脚本 @@ -100,7 +100,7 @@ const features = { ## 新增功能设定区块 -在 `src/settings/features/` 目录下新增一个新的功能设定区块,例如 `src/settings/features/hello-world.tsx`: +在 `src/options/features/` 目录下新增一个新的功能设定区块,例如 `src/options/features/hello-world.tsx`: ```tsx @@ -172,7 +172,7 @@ export default function HelloFeatureSettings(): JSX.Element { 有关设定的更多详细信息,你可以参考 [`docs/settings.md`](/docs/settings.md) 文档。 -完成後,別忘了到 `src/settings/features/index.ts` 中新增你的功能设定区块: +完成後,別忘了到 `src/options/features/index.ts` 中新增你的功能设定区块: ```ts @@ -197,9 +197,9 @@ const featureSettings = { 你可以参考以下的源码以进行更进阶的功能开发: - [现成功能参考](/src/features/) -- [功能设定区块参考](/src/settings/features/) +- [功能设定区块参考](/src/options/features/) - [自定义Hooks](/src/hooks/) - [自定义Context](/src/contexts/) - [辅助函数](/src/utils/) - [全局组件](/src/components) -- [设定区块用组件](/src/settings/components/) \ No newline at end of file +- [设定区块用组件](/src/options/components/) \ No newline at end of file diff --git a/docs/settings.md b/docs/settings.md index 14e878d4..5c2f5892 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,13 +4,13 @@ ``` src/ - settings/ + options/ fragments/ <- 设定区块列表 ``` ## 创建一个新的设定区块 -在 `src/settings/fragments/` 目录下新增一个新的设定区块,例如 `src/settings/fragments/hello-world.tsx`。 +在 `src/options/fragments/` 目录下新增一个新的设定区块,例如 `src/options/fragments/hello-world.tsx`。 ```tsx @@ -110,7 +110,7 @@ function HelloWorldSettings({ state, useHandler }: StateProxy): J > 有关 `StateProxy` 的使用,请参考 [自定义Hooks](/src/hooks/binding.ts)。 -完成后,你需要到 `src/settings/index.ts` 中新增你的设定区块: +完成后,你需要到 `src/options/fragments.ts` 中新增你的设定区块: ```ts // ... @@ -175,8 +175,8 @@ function ContentScript(): JSX.Element { 你可以参考以下的源码以进行更进阶的设定开发: -- [现成的设定区块参考](/src/settings/fragments/) -- [设定区块组件](/src/settings/components/) +- [现成的设定区块参考](/src/options/fragments/) +- [设定区块组件](/src/options/components/) - [自定义Hooks](/src/hooks/) - [自定义Context](/src/contexts/) - [辅助函数](/src/utils/) diff --git a/src/adapters/capture-element.ts b/src/adapters/capture-element.ts index 9f61fef1..1cce2df2 100644 --- a/src/adapters/capture-element.ts +++ b/src/adapters/capture-element.ts @@ -2,7 +2,7 @@ import { injectFuncAsListener } from '~utils/event' import { sendBLiveMessage } from '~utils/messaging' import { md5 } from 'hash-wasm' -import type { Settings } from "~settings" +import type { Settings } from "~options/fragments" // mutation observer danmaku must be text node type DanmakuMutation = { content: string diff --git a/src/background/index.ts b/src/background/index.ts index 93c81c8c..33d5c883 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -6,7 +6,7 @@ import { sendInternal } from './messages' // browser extension icon click listener chrome.action.onClicked.addListener(() => { - sendInternal('open-tab', { tab: 'settings' }) + chrome.runtime.openOptionsPage() }) chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }) diff --git a/src/background/messages.ts b/src/background/messages.ts index e3340645..dc997c96 100644 --- a/src/background/messages.ts +++ b/src/background/messages.ts @@ -10,6 +10,7 @@ import * as openTab from './messages/open-tab' import * as openWindow from './messages/open-window' import * as request from './messages/request' import * as migrationMv2 from './messages/migration-mv2' +import * as openOptions from './messages/open-options' import type { PlasmoMessaging } from "@plasmohq/messaging" import { isBackgroundScript } from '~utils/file' @@ -69,5 +70,6 @@ const messagers = { 'inject-script': injectScript, 'add-black-list': addBlackList, 'hook-adapter': hookAdapter, - 'migration-mv2': migrationMv2 + 'migration-mv2': migrationMv2, + 'open-options': openOptions, } \ No newline at end of file diff --git a/src/background/messages/fetch-developer.ts b/src/background/messages/fetch-developer.ts index 90bf73bf..e9d33a6f 100644 --- a/src/background/messages/fetch-developer.ts +++ b/src/background/messages/fetch-developer.ts @@ -1,6 +1,6 @@ import type { PlasmoMessaging } from "@plasmohq/messaging" import { sendInternal } from '~background/messages' -import type { SettingSchema as DeveloperSchema } from "~settings/fragments/developer" +import type { SettingSchema as DeveloperSchema } from "~options/fragments/developer" const developerLink = `https://cdn.jsdelivr.net/gh/eric2788/bilibili-vup-stream-enhancer@web/cdn/developer_v2.json` diff --git a/src/background/messages/hook-adapter.ts b/src/background/messages/hook-adapter.ts index d3716d67..4ccdd79d 100644 --- a/src/background/messages/hook-adapter.ts +++ b/src/background/messages/hook-adapter.ts @@ -4,7 +4,7 @@ import { adapters } from '~adapters' import { sendInternal } from '~background/messages' import { getResourceName } from '~utils/file' -import type { Settings } from "~settings" +import type { Settings } from "~options/fragments" import type { FuncEventResult } from "~utils/event" export type AdaptOperation = 'hook' | 'unhook' diff --git a/src/background/messages/migration-mv2.ts b/src/background/messages/migration-mv2.ts index b821c783..91bdb7cc 100644 --- a/src/background/messages/migration-mv2.ts +++ b/src/background/messages/migration-mv2.ts @@ -1,6 +1,6 @@ import type { PlasmoMessaging } from "@plasmohq/messaging"; import migrateFromMV2 from "~migrations"; -import type { Settings } from "~settings"; +import type { Settings } from "~options/fragments"; export type ResponseBody = { data?: Settings, error?: string } diff --git a/src/background/messages/open-options.ts b/src/background/messages/open-options.ts new file mode 100644 index 00000000..fdbfcb91 --- /dev/null +++ b/src/background/messages/open-options.ts @@ -0,0 +1,7 @@ +import type { PlasmoMessaging } from "@plasmohq/messaging"; + +const handler: PlasmoMessaging.Handler = async (req, res) => { + await new Promise((res, ) => chrome.runtime.openOptionsPage(res)) +} + +export default handler \ No newline at end of file diff --git a/src/components/OfflineRecordsProvider.tsx b/src/components/OfflineRecordsProvider.tsx index 116d555a..9e3b77de 100644 --- a/src/components/OfflineRecordsProvider.tsx +++ b/src/components/OfflineRecordsProvider.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'; import PromiseHandler from '~components/PromiseHandler'; import db, { type RecordType, type TableType } from '~database'; import type { FeatureType } from "~features"; -import type { Settings } from "~settings"; +import type { Settings } from "~options/fragments"; /** diff --git a/src/contents/index/components/ButtonList.tsx b/src/contents/index/components/ButtonList.tsx index fe369977..dfc48073 100644 --- a/src/contents/index/components/ButtonList.tsx +++ b/src/contents/index/components/ButtonList.tsx @@ -20,7 +20,7 @@ function ButtonList(): JSX.Element { const restart = useCallback(() => sendForward('background', 'redirect', { target: 'content-script', command: 'command', body: { command: 'restart' }, queryInfo: { url: '*://live.bilibili.com/*' } }), []) const addBlackList = () => confirm(`确定添加房间 ${info.room}${info.room === info.shortRoom ? '' : `(${info.shortRoom})`} 到黑名单?`) && sendMessager('add-black-list', { roomId: info.room }) - const openSettings = useCallback(() => sendMessager('open-tab', { tab: 'settings' }), []) + const openSettings = useCallback(() => sendMessager('open-options'), []) const openMonitor = createPopupWindow(`stream.html`, { roomId: info.room, title: info.title, diff --git a/src/contents/index/mounter.tsx b/src/contents/index/mounter.tsx index cb149985..515b5339 100644 --- a/src/contents/index/mounter.tsx +++ b/src/contents/index/mounter.tsx @@ -7,8 +7,8 @@ import BLiveThemeProvider from "~components/BLiveThemeProvider" import ContentContext from "~contexts/ContentContexts" import type { FeatureType } from "~features" import features from "~features" -import type { Settings } from "~settings" -import { shouldInit } from "~settings" +import type { Settings } from "~options/fragments" +import { shouldInit } from "~options/fragments" import { getStreamInfoByDom } from "~utils/bilibili" import { injectAdapter } from "~utils/inject" import { addBLiveMessageCommandListener, sendMessager } from "~utils/messaging" diff --git a/src/contexts/ContentContexts.ts b/src/contexts/ContentContexts.ts index af930099..c94361a8 100644 --- a/src/contexts/ContentContexts.ts +++ b/src/contexts/ContentContexts.ts @@ -1,6 +1,6 @@ import { createContext } from "react"; import type { StreamInfo } from "~api/bilibili"; -import type { Settings } from "~settings"; +import type { Settings } from "~options/fragments"; export type ContentContextProps = { info: StreamInfo diff --git a/src/contexts/JimakuFeatureContext.ts b/src/contexts/JimakuFeatureContext.ts index 5999f0d6..bff645af 100644 --- a/src/contexts/JimakuFeatureContext.ts +++ b/src/contexts/JimakuFeatureContext.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import { type FeatureSettingSchema as FeatureJimakuSchema } from "~settings/features/jimaku"; +import { type FeatureSettingSchema as FeatureJimakuSchema } from "~options/features/jimaku"; const JimakuFeatureContext = createContext(null) diff --git a/src/contexts/RecorderFeatureContext.ts b/src/contexts/RecorderFeatureContext.ts index 4bed63cb..cadc47ee 100644 --- a/src/contexts/RecorderFeatureContext.ts +++ b/src/contexts/RecorderFeatureContext.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import type { FeatureSettingSchema as RecorderFeatureSchema } from "~settings/features/recorder"; +import type { FeatureSettingSchema as RecorderFeatureSchema } from "~options/features/recorder"; const RecorderFeatureContext = createContext(null) diff --git a/src/contexts/SuperChatFeatureContext.ts b/src/contexts/SuperChatFeatureContext.ts index 40ad93e3..ddf58147 100644 --- a/src/contexts/SuperChatFeatureContext.ts +++ b/src/contexts/SuperChatFeatureContext.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import type { FeatureSettingSchema as SuperChatFeatureSchema } from "~settings/features/superchat"; +import type { FeatureSettingSchema as SuperChatFeatureSchema } from "~options/features/superchat"; const SuperChatFeatureContext = createContext(null) diff --git a/src/features/index.ts b/src/features/index.ts index cabba89f..d45cb98a 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -3,7 +3,7 @@ import * as superchat from './superchat' import * as recorder from './recorder' import type { StreamInfo } from '~api/bilibili' -import type { Settings } from '~settings' +import type { Settings } from '~options/fragments' export type FeatureHookRender = (settings: Readonly, info: StreamInfo) => Promise<(React.ReactPortal | React.ReactNode)[] | undefined> diff --git a/src/features/jimaku/components/JimakuArea.tsx b/src/features/jimaku/components/JimakuArea.tsx index eea9ece7..3f693053 100644 --- a/src/features/jimaku/components/JimakuArea.tsx +++ b/src/features/jimaku/components/JimakuArea.tsx @@ -6,7 +6,7 @@ import ContentContext from '~contexts/ContentContexts'; import JimakuFeatureContext from '~contexts/JimakuFeatureContext'; import { useWebScreenChange } from '~hooks/bilibili'; import { useTeleport } from '~hooks/teleport'; -import type { JimakuSchema } from '~settings/features/jimaku/components/JimakuFragment'; +import type { JimakuSchema } from '~options/features/jimaku/components/JimakuFragment'; import { rgba } from '~utils/misc'; import type { Jimaku } from "./JimakuLine"; import JimakuList from './JimakuList'; diff --git a/src/features/jimaku/components/JimakuList.tsx b/src/features/jimaku/components/JimakuList.tsx index 33487288..7eb51462 100644 --- a/src/features/jimaku/components/JimakuList.tsx +++ b/src/features/jimaku/components/JimakuList.tsx @@ -12,7 +12,7 @@ import styleText from 'data-text:react-contexify/dist/ReactContexify.css'; import { useContext, useRef } from "react"; import 'react-contexify/dist/ReactContexify.css'; import JimakuFeatureContext from "~contexts/JimakuFeatureContext"; -import type { UserRecord } from "~settings/features/jimaku/components/ListingFragment"; +import type { UserRecord } from "~options/features/jimaku/components/ListingFragment"; import ShadowStyle from "~components/ShadowStyle"; diff --git a/src/features/jimaku/components/JimakuVisibleButton.tsx b/src/features/jimaku/components/JimakuVisibleButton.tsx index 56b66cf9..cc47e09d 100644 --- a/src/features/jimaku/components/JimakuVisibleButton.tsx +++ b/src/features/jimaku/components/JimakuVisibleButton.tsx @@ -1,6 +1,6 @@ import { createPortal } from 'react-dom'; -import type { SettingSchema } from "~settings/fragments/developer"; +import type { SettingSchema } from "~options/fragments/developer"; export type JimakuVisibleButtonProps = { toggle: VoidFunction diff --git a/src/hooks/bilibili.ts b/src/hooks/bilibili.ts index 0b6620f1..84cda223 100644 --- a/src/hooks/bilibili.ts +++ b/src/hooks/bilibili.ts @@ -1,6 +1,6 @@ import { useMutationObserver } from '@react-hooks-library/core' import { useState } from 'react' -import { type SettingSchema as DeveloperSchema } from '~settings/fragments/developer' +import { type SettingSchema as DeveloperSchema } from '~options/fragments/developer' export type WebScreenStatus = 'normal' | 'web-fullscreen' | 'fullscreen' diff --git a/src/migrations/index.ts b/src/migrations/index.ts index cebaef30..0824fb17 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -2,8 +2,8 @@ import { getNestedValue, setNestedValue } from "~utils/misc"; import storage, { getFullSettingStroage, getSettingStorage, setSettingStorage } from "~utils/storage"; import migrations, { addMigrationMapping, addMigrationTransfer, type MV2Settings, type MV2SettingsMapping } from "./schema"; import type { FeatureType } from "~features"; -import type { Settings } from "~settings"; -import fragments from "~settings"; +import type { Settings } from "~options/fragments"; +import fragments from "~options/fragments"; addMigrationMapping('regex', 'settings.features', 'jimaku.danmakuZone.regex') diff --git a/src/migrations/schema.ts b/src/migrations/schema.ts index 9f7baae2..6ee72d01 100644 --- a/src/migrations/schema.ts +++ b/src/migrations/schema.ts @@ -1,4 +1,4 @@ -import type { Schema, SettingFragments } from "~settings" +import type { Schema, SettingFragments } from "~options/fragments" import type { Leaves, PathLeafType, PickLeaves } from "~types/common"; export interface MV2Settings { diff --git a/src/settings/components/AffixInput.tsx b/src/options/components/AffixInput.tsx similarity index 100% rename from src/settings/components/AffixInput.tsx rename to src/options/components/AffixInput.tsx diff --git a/src/settings/components/CheckBoxListItem.tsx b/src/options/components/CheckBoxListItem.tsx similarity index 100% rename from src/settings/components/CheckBoxListItem.tsx rename to src/options/components/CheckBoxListItem.tsx diff --git a/src/settings/components/ColorInput.tsx b/src/options/components/ColorInput.tsx similarity index 100% rename from src/settings/components/ColorInput.tsx rename to src/options/components/ColorInput.tsx diff --git a/src/settings/components/DataTable.tsx b/src/options/components/DataTable.tsx similarity index 100% rename from src/settings/components/DataTable.tsx rename to src/options/components/DataTable.tsx diff --git a/src/settings/components/DeleteIcon.tsx b/src/options/components/DeleteIcon.tsx similarity index 100% rename from src/settings/components/DeleteIcon.tsx rename to src/options/components/DeleteIcon.tsx diff --git a/src/settings/components/Expander.tsx b/src/options/components/Expander.tsx similarity index 100% rename from src/settings/components/Expander.tsx rename to src/options/components/Expander.tsx diff --git a/src/settings/components/ExperientmentFeatureIcon.tsx b/src/options/components/ExperientmentFeatureIcon.tsx similarity index 100% rename from src/settings/components/ExperientmentFeatureIcon.tsx rename to src/options/components/ExperientmentFeatureIcon.tsx diff --git a/src/settings/components/FeatureRoomTable.tsx b/src/options/components/FeatureRoomTable.tsx similarity index 100% rename from src/settings/components/FeatureRoomTable.tsx rename to src/options/components/FeatureRoomTable.tsx diff --git a/src/settings/components/Hints.tsx b/src/options/components/Hints.tsx similarity index 100% rename from src/settings/components/Hints.tsx rename to src/options/components/Hints.tsx diff --git a/src/settings/components/HotKeyInput.tsx b/src/options/components/HotKeyInput.tsx similarity index 100% rename from src/settings/components/HotKeyInput.tsx rename to src/options/components/HotKeyInput.tsx diff --git a/src/settings/components/Selector.tsx b/src/options/components/Selector.tsx similarity index 100% rename from src/settings/components/Selector.tsx rename to src/options/components/Selector.tsx diff --git a/src/settings/components/SettingFragment.tsx b/src/options/components/SettingFragment.tsx similarity index 99% rename from src/settings/components/SettingFragment.tsx rename to src/options/components/SettingFragment.tsx index 12871157..386bf3e5 100644 --- a/src/settings/components/SettingFragment.tsx +++ b/src/options/components/SettingFragment.tsx @@ -6,7 +6,7 @@ import { import PromiseHandler from '~components/PromiseHandler'; import { asStateProxy, useBinding, type StateProxy } from '~hooks/binding'; import { useForceUpdate } from '~hooks/force-update'; -import fragments, { type Schema, type SettingFragments } from '~settings'; +import fragments, { type Schema, type SettingFragments } from '~options/fragments'; import { deepCopy } from '~utils/misc'; import { getSettingStorage, setSettingStorage } from '~utils/storage'; diff --git a/src/settings/components/SwitchListItem.tsx b/src/options/components/SwitchListItem.tsx similarity index 100% rename from src/settings/components/SwitchListItem.tsx rename to src/options/components/SwitchListItem.tsx diff --git a/src/settings/features/index.ts b/src/options/features/index.ts similarity index 100% rename from src/settings/features/index.ts rename to src/options/features/index.ts diff --git a/src/settings/features/jimaku/components/ButtonFragment.tsx b/src/options/features/jimaku/components/ButtonFragment.tsx similarity index 95% rename from src/settings/features/jimaku/components/ButtonFragment.tsx rename to src/options/features/jimaku/components/ButtonFragment.tsx index 0201d77b..68b55edd 100644 --- a/src/settings/features/jimaku/components/ButtonFragment.tsx +++ b/src/options/features/jimaku/components/ButtonFragment.tsx @@ -1,6 +1,6 @@ import { type ChangeEvent, Fragment } from 'react'; -import ColorInput from '~settings/components/ColorInput'; +import ColorInput from '~options/components/ColorInput'; import type { StateProxy } from "~hooks/binding"; import type { HexColor } from "~types/common"; diff --git a/src/settings/features/jimaku/components/DanmakuFragment.tsx b/src/options/features/jimaku/components/DanmakuFragment.tsx similarity index 94% rename from src/settings/features/jimaku/components/DanmakuFragment.tsx rename to src/options/features/jimaku/components/DanmakuFragment.tsx index 7e8318a6..b6fe1273 100644 --- a/src/settings/features/jimaku/components/DanmakuFragment.tsx +++ b/src/options/features/jimaku/components/DanmakuFragment.tsx @@ -1,8 +1,8 @@ import { type ChangeEvent, Fragment } from 'react'; -import AffixInput from '~settings/components/AffixInput'; -import ColorInput from '~settings/components/ColorInput'; -import Hints from '~settings/components/Hints'; -import Selector from '~settings/components/Selector'; +import AffixInput from '~options/components/AffixInput'; +import ColorInput from '~options/components/ColorInput'; +import Hints from '~options/components/Hints'; +import Selector from '~options/components/Selector'; import { sendMessager } from '~utils/messaging'; import { Input, Switch, Typography } from '@material-tailwind/react'; diff --git a/src/settings/features/jimaku/components/JimakuFragment.tsx b/src/options/features/jimaku/components/JimakuFragment.tsx similarity index 95% rename from src/settings/features/jimaku/components/JimakuFragment.tsx rename to src/options/features/jimaku/components/JimakuFragment.tsx index 7cd9aa33..b28ca85e 100644 --- a/src/settings/features/jimaku/components/JimakuFragment.tsx +++ b/src/options/features/jimaku/components/JimakuFragment.tsx @@ -1,11 +1,11 @@ import { List } from "@material-tailwind/react" import { type ChangeEvent, Fragment } from "react" import type { StateProxy } from "~hooks/binding" -import AffixInput from "~settings/components/AffixInput" -import ColorInput from "~settings/components/ColorInput" -import Hints from "~settings/components/Hints" -import Selector from "~settings/components/Selector" -import SwitchListItem from "~settings/components/SwitchListItem" +import AffixInput from "~options/components/AffixInput" +import ColorInput from "~options/components/ColorInput" +import Hints from "~options/components/Hints" +import Selector from "~options/components/Selector" +import SwitchListItem from "~options/components/SwitchListItem" import type { HundredNumber, HexColor, NumRange } from "~types/common" export type JimakuSchema = { diff --git a/src/settings/features/jimaku/components/ListingFragment.tsx b/src/options/features/jimaku/components/ListingFragment.tsx similarity index 96% rename from src/settings/features/jimaku/components/ListingFragment.tsx rename to src/options/features/jimaku/components/ListingFragment.tsx index d1b063f8..e08b6197 100644 --- a/src/settings/features/jimaku/components/ListingFragment.tsx +++ b/src/options/features/jimaku/components/ListingFragment.tsx @@ -2,9 +2,9 @@ import { Fragment } from "react" import { toast } from "sonner/dist" import { requestUserInfo } from "~api/bilibili" import type { ExposeHandler, StateProxy } from "~hooks/binding" -import type { TableHeader } from "~settings/components/DataTable" -import DataTable from "~settings/components/DataTable" -import DeleteIcon from "~settings/components/DeleteIcon" +import type { TableHeader } from "~options/components/DataTable" +import DataTable from "~options/components/DataTable" +import DeleteIcon from "~options/components/DeleteIcon" import type { WbiAccInfoResponse } from "~types/bilibili" import type { ArrElement, PickKeys } from "~types/common" import { catcher } from "~utils/fetch" diff --git a/src/settings/features/jimaku/index.tsx b/src/options/features/jimaku/index.tsx similarity index 95% rename from src/settings/features/jimaku/index.tsx rename to src/options/features/jimaku/index.tsx index 63a97e9f..452f711b 100644 --- a/src/settings/features/jimaku/index.tsx +++ b/src/options/features/jimaku/index.tsx @@ -1,15 +1,15 @@ import { Collapse, List } from "@material-tailwind/react" import { Fragment, type ChangeEvent } from "react" import { asStateProxy, useBinding, type StateProxy } from "~hooks/binding" -import ExperienmentFeatureIcon from "~settings/components/ExperientmentFeatureIcon" -import SwitchListItem from "~settings/components/SwitchListItem" +import ExperienmentFeatureIcon from "~options/components/ExperientmentFeatureIcon" +import SwitchListItem from "~options/components/SwitchListItem" import type { FeatureSettingsDefinition } from ".." import ButtonFragment, { buttonDefaultSettings, type ButtonSchema } from "./components/ButtonFragment" import DanmakuZone, { danmakuDefaultSettings, type DanmakuSchema } from "./components/DanmakuFragment" import JimakuZone, { jimakuDefaultSettings, type JimakuSchema } from "./components/JimakuFragment" import ListingFragment, { listingDefaultSettings, type ListingSchema } from "./components/ListingFragment" import { useToggle } from "@react-hooks-library/core" -import Expander from "~settings/components/Expander" +import Expander from "~options/components/Expander" import type { PickKeys } from "~types/common" diff --git a/src/settings/features/recorder/index.tsx b/src/options/features/recorder/index.tsx similarity index 96% rename from src/settings/features/recorder/index.tsx rename to src/options/features/recorder/index.tsx index 43e854b8..16deff9c 100644 --- a/src/settings/features/recorder/index.tsx +++ b/src/options/features/recorder/index.tsx @@ -2,10 +2,10 @@ import { List, Switch, Typography } from "@material-tailwind/react" import { Fragment, type ChangeEvent } from "react" import type { RecorderType } from "~features/recorder/recorders" import type { StateProxy } from "~hooks/binding" -import { HotKeyInput, type HotKey } from "~settings/components/HotKeyInput" -import Selector from "~settings/components/Selector" +import { HotKeyInput, type HotKey } from "~options/components/HotKeyInput" +import Selector from "~options/components/Selector" import type { FeatureSettingsDefinition } from ".." -import SwitchListItem from "~settings/components/SwitchListItem" +import SwitchListItem from "~options/components/SwitchListItem" import type { PlayerType } from "~players" import { toast } from "sonner/dist" diff --git a/src/settings/features/superchat/index.tsx b/src/options/features/superchat/index.tsx similarity index 96% rename from src/settings/features/superchat/index.tsx rename to src/options/features/superchat/index.tsx index 2325e2e9..e050bafa 100644 --- a/src/settings/features/superchat/index.tsx +++ b/src/options/features/superchat/index.tsx @@ -1,7 +1,7 @@ import { Switch, Typography } from "@material-tailwind/react" import { Fragment, type ChangeEvent } from "react" import type { StateProxy } from "~hooks/binding" -import ColorInput from "~settings/components/ColorInput" +import ColorInput from "~options/components/ColorInput" import type { HexColor } from "~types/common" import type { FeatureSettingsDefinition } from ".." diff --git a/src/settings/index.ts b/src/options/fragments.ts similarity index 100% rename from src/settings/index.ts rename to src/options/fragments.ts diff --git a/src/settings/fragments/capture.tsx b/src/options/fragments/capture.tsx similarity index 95% rename from src/settings/fragments/capture.tsx rename to src/options/fragments/capture.tsx index ba69e737..c8ea2852 100644 --- a/src/settings/fragments/capture.tsx +++ b/src/options/fragments/capture.tsx @@ -2,10 +2,10 @@ import { Fragment, type ChangeEvent } from 'react'; import { toast } from 'sonner/dist'; import { type StateProxy } from '~hooks/binding'; -import Selector from '~settings/components/Selector'; +import Selector from '~options/components/Selector'; import type { AdapterType } from '~adapters'; -import SwitchListItem from '~settings/components/SwitchListItem'; +import SwitchListItem from '~options/components/SwitchListItem'; import { List } from '@material-tailwind/react'; export type SettingSchema = { diff --git a/src/settings/fragments/developer.tsx b/src/options/fragments/developer.tsx similarity index 100% rename from src/settings/fragments/developer.tsx rename to src/options/fragments/developer.tsx diff --git a/src/settings/fragments/display.tsx b/src/options/fragments/display.tsx similarity index 97% rename from src/settings/fragments/display.tsx rename to src/options/fragments/display.tsx index 08687b23..96520e2c 100644 --- a/src/settings/fragments/display.tsx +++ b/src/options/fragments/display.tsx @@ -1,5 +1,5 @@ import { type ChangeEvent, Fragment } from 'react'; -import CheckBoxListItem from '~settings/components/CheckBoxListItem'; +import CheckBoxListItem from '~options/components/CheckBoxListItem'; import { List } from '@material-tailwind/react'; diff --git a/src/settings/fragments/features.tsx b/src/options/fragments/features.tsx similarity index 96% rename from src/settings/fragments/features.tsx rename to src/options/fragments/features.tsx index 87ed45e0..800b9ca1 100644 --- a/src/settings/fragments/features.tsx +++ b/src/options/fragments/features.tsx @@ -1,6 +1,6 @@ import { useCallback, type ChangeEvent } from 'react'; import { ensureIsVtuber, type StreamInfo } from '~api/bilibili'; -import SwitchListItem from '~settings/components/SwitchListItem'; +import SwitchListItem from '~options/components/SwitchListItem'; import { sendMessager } from '~utils/messaging'; import { Collapse, IconButton, List, Switch, Typography } from '@material-tailwind/react'; @@ -9,9 +9,9 @@ import { toast } from 'sonner/dist'; import type { TableType } from "~database"; import type { FeatureType } from "~features"; import { asStateProxy, useBinding, type StateProxy } from "~hooks/binding"; -import ExperienmentFeatureIcon from '~settings/components/ExperientmentFeatureIcon'; -import FeatureRoomTable from '~settings/components/FeatureRoomTable'; -import settings, { featureTypes, type FeatureSettingSchema, type FeatureSettings } from '~settings/features'; +import ExperienmentFeatureIcon from '~options/components/ExperientmentFeatureIcon'; +import FeatureRoomTable from '~options/components/FeatureRoomTable'; +import settings, { featureTypes, type FeatureSettingSchema, type FeatureSettings } from '~options/features'; import type { RoomList } from '~types/common'; @@ -134,7 +134,7 @@ function FeatureSettings({ state, useHandler }: StateProxy): JSX.
{...featureTypes.map((f: FeatureType) => { - // mechanism same as src/settings/components/SettingFragment.tsx + // mechanism same as src/options/components/SettingFragment.tsx type F = typeof f const setting = settings[f] as FeatureSettings[F] const Component = setting.default as React.FC>> diff --git a/src/settings/fragments/listings.tsx b/src/options/fragments/listings.tsx similarity index 96% rename from src/settings/fragments/listings.tsx rename to src/options/fragments/listings.tsx index ca021775..0c961d27 100644 --- a/src/settings/fragments/listings.tsx +++ b/src/options/fragments/listings.tsx @@ -1,14 +1,14 @@ import { Fragment, type ChangeEvent } from 'react'; import { type StreamInfo } from '~api/bilibili'; -import DataTable, { type TableHeader } from '~settings/components/DataTable'; +import DataTable, { type TableHeader } from '~options/components/DataTable'; import { removeArr } from '~utils/misc'; import { Switch, Typography } from '@material-tailwind/react'; import { toast } from 'sonner/dist'; import type { StateProxy } from "~hooks/binding"; -import DeleteIcon from '~settings/components/DeleteIcon'; +import DeleteIcon from '~options/components/DeleteIcon'; import type { ArrElement } from "~types/common"; export type SettingSchema = { diff --git a/src/settings/fragments/version.tsx b/src/options/fragments/version.tsx similarity index 98% rename from src/settings/fragments/version.tsx rename to src/options/fragments/version.tsx index c73bf643..eb29cc1e 100644 --- a/src/settings/fragments/version.tsx +++ b/src/options/fragments/version.tsx @@ -3,7 +3,7 @@ import { type ChangeEvent, Fragment, useState } from "react" import { getLatestRelease, getRelease } from "~api/github" import PromiseHandler from "~components/PromiseHandler" import type { StateProxy } from "~hooks/binding" -import SwitchListItem from "~settings/components/SwitchListItem" +import SwitchListItem from "~options/components/SwitchListItem" import type { ReleaseInfo } from "~types/github" import semver from 'semver'; diff --git a/src/tabs/settings.tsx b/src/options/index.tsx similarity index 99% rename from src/tabs/settings.tsx rename to src/options/index.tsx index 799366f4..fe688dd2 100644 --- a/src/tabs/settings.tsx +++ b/src/options/index.tsx @@ -5,8 +5,8 @@ import BJFThemeProvider from '~components/BJFThemeProvider'; import { useBinding } from '~hooks/binding'; import { useForwarder } from '~hooks/forwarder'; import { useLoader } from '~hooks/loader'; -import fragments, { type Schema, type SettingFragments, type Settings } from '~settings'; -import SettingFragment, { type SettingFragmentRef } from '~settings/components/SettingFragment'; +import fragments, { type Schema, type SettingFragments, type Settings } from '~options/fragments'; +import SettingFragment, { type SettingFragmentRef } from '~options/components/SettingFragment'; import { download, readAsJson } from '~utils/file'; import { sendMessager } from '~utils/messaging'; import { arrayEqual, removeInvalidKeys } from '~utils/misc'; diff --git a/src/utils/bilibili.ts b/src/utils/bilibili.ts index adc4067a..db0d2b60 100644 --- a/src/utils/bilibili.ts +++ b/src/utils/bilibili.ts @@ -2,7 +2,7 @@ import type { V1Response, WebInterfaceNavResponse } from "~types/bilibili" import { md5 } from 'hash-wasm' import type { StreamInfo } from "~api/bilibili" -import type { Settings } from "~settings" +import type { Settings } from "~options/fragments" import { sendRequest } from "./fetch" import { localStorage } from './storage' diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 60f98646..323a67f5 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,4 +1,4 @@ -import fragments, { type Schema, type SettingFragments, type Settings } from '~settings' +import fragments, { type Schema, type SettingFragments, type Settings } from '~options/fragments' import { Storage } from '@plasmohq/storage' import { assignDefaults } from './misc' diff --git a/tests/content.spec.ts b/tests/content.spec.ts index 197de45c..cc0c304d 100644 --- a/tests/content.spec.ts +++ b/tests/content.spec.ts @@ -85,13 +85,13 @@ test('測試是否挂接成功', async ({ room }) => { }) -test('測試名單列表(黑名單/白名單)', async ({ context, content, tabUrl, room }) => { +test('測試名單列表(黑名單/白名單)', async ({ context, content, optionPageUrl, room }) => { const button = content.getByText('功能菜单') await expect(button).toBeVisible() const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('名单列表').click() const roomInput = settingsPage.getByTestId('black-list-rooms-input') await roomInput.fill(room.info.roomid.toString()) @@ -114,17 +114,17 @@ test('測試名單列表(黑名單/白名單)', async ({ context, content, tabUr }) -test('測試进入设置按鈕', async ({ context, content, tabUrl }) => { +test('測試进入设置按鈕', async ({ context, content, optionPageUrl }) => { await content.getByText('功能菜单').click() await content.locator('#bjf-main-menu').waitFor({ state: 'visible' }) - const popup = context.waitForEvent('page', { predicate: p => p.url().includes('settings.html') }) + const popup = context.waitForEvent('page', { predicate: p => p.url().includes('options.html') }) await content.getByText('进入设置').click() const settings = await popup - expect(settings.url()).toBe(tabUrl('settings.html')) + expect(settings.url()).toBe(optionPageUrl) }) @@ -145,11 +145,11 @@ test('測試添加到黑名单按鈕', async ({ content, page, room }) => { }) -test('測試重新启动按鈕', async ({ content, tabUrl, context }) => { +test('測試重新启动按鈕', async ({ content, optionPageUrl, context }) => { const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('界面按钮显示').click() await settingsPage.getByText('重新启动按钮').click() await settingsPage.getByText('保存设定').click() @@ -170,10 +170,10 @@ test('測試重新启动按鈕', async ({ content, tabUrl, context }) => { }) -test('測試弹出直播视窗按鈕', async ({ context, tabUrl, content }) => { +test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content }) => { const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByText('启用弹出直播视窗').click() await settingsPage.getByText('保存设定').click() @@ -253,7 +253,7 @@ test('測試大海報房間下返回非海报界面按鈕', async ({ context, th }) -test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ content, context, tabUrl }) => { +test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ content, context, optionPageUrl }) => { const button = content.getByText('功能菜单') await expect(button).toBeVisible() @@ -265,7 +265,7 @@ test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ conte logger.info('正在修改設定...') const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('界面按钮显示').click() await settingsPage.getByText('支持在网页全屏下显示').click() // enabled await settingsPage.getByText('保存设定').click() @@ -281,7 +281,7 @@ test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ conte await expect(button).toBeVisible() }) -test('测试仅限虚拟主播', async ({ context, room, tabUrl, api }) => { +test('测试仅限虚拟主播', async ({ context, room, optionPageUrl, api }) => { const nonVtbRooms = await api.getLiveRooms(1, 11) // 获取知识分区直播间 test.skip(nonVtbRooms.length === 0, '没有知识分区直播间') @@ -293,7 +293,7 @@ test('测试仅限虚拟主播', async ({ context, room, tabUrl, api }) => { await expect(button).toBeHidden() const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByText('仅限虚拟主播').click() await settingsPage.getByText('保存设定').click() diff --git a/tests/features/jimaku.spec.ts b/tests/features/jimaku.spec.ts index 376f8520..d971c4ec 100644 --- a/tests/features/jimaku.spec.ts +++ b/tests/features/jimaku.spec.ts @@ -74,12 +74,12 @@ test('測試字幕按鈕 (刪除/下載)', async ({ room, content: p, page }) => }) -test('測試彈出同傳視窗', async ({ room, context, tabUrl, page, content }) => { +test('測試彈出同傳視窗', async ({ room, context, optionPageUrl, page, content }) => { // modify settings logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.waitForTimeout(1000) await settingsPage.getByText('功能设定').click() @@ -126,12 +126,12 @@ test('測試彈出同傳視窗', async ({ room, context, tabUrl, page, content } }) -test('測試離線記錄彈幕', async ({ room, content: p, context, tabUrl, page }) => { +test('測試離線記錄彈幕', async ({ room, content: p, context, optionPageUrl, page }) => { logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.waitForTimeout(1000) await settingsPage.getByText('功能设定').click() @@ -181,7 +181,7 @@ test('測試房間名單列表(黑名單/白名單)', ) ) -test('测试添加同传用户名单/黑名单', async ({ content, context, tabUrl, room }) => { +test('测试添加同传用户名单/黑名单', async ({ content, context, optionPageUrl, room }) => { const subtitleList = content.locator('#subtitle-list') await expect(subtitleList).toBeVisible() @@ -205,7 +205,7 @@ test('测试添加同传用户名单/黑名单', async ({ content, context, tabU logger.info('正在測試添加同傳用戶名單...') const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByText('同传名单设定').click() @@ -321,12 +321,12 @@ test('測試全屏時字幕區塊是否存在 + 顯示切換', async ({ content: }) -test('測試保存設定後 css 能否生效', async ({ context, content, tabUrl, page, room }) => { +test('測試保存設定後 css 能否生效', async ({ context, content, optionPageUrl, page, room }) => { logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.waitForTimeout(1000) await settingsPage.getByText('功能设定').click() diff --git a/tests/features/recorder.spec.ts b/tests/features/recorder.spec.ts index b7017d1b..ef6fcf87 100644 --- a/tests/features/recorder.spec.ts +++ b/tests/features/recorder.spec.ts @@ -4,7 +4,7 @@ import { readMovieInfo } from "@tests/utils/file" import { testFeatureRoomList } from "@tests/utils/playwright" import fs from 'fs/promises' -test.beforeEach(async ({ content, context, tabUrl, isThemeRoom }) => { +test.beforeEach(async ({ content, context, optionPageUrl, isThemeRoom }) => { logger.info('正在整理 out 文件夾...') await fs.rm('out', { recursive: true, force: true }) @@ -22,7 +22,7 @@ test.beforeEach(async ({ content, context, tabUrl, isThemeRoom }) => { logger.info('正在啟用功能...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByText('启用快速切片').click() await settingsPage.getByText("保存设定").click() @@ -40,7 +40,7 @@ test('測試功能元素是否存在', async ({ content }) => { }) -test('測試錄製按鈕有否根據設定顯示', async ({ content, context, tabUrl }) => { +test('測試錄製按鈕有否根據設定顯示', async ({ content, context, optionPageUrl }) => { const button = content.getByTestId('record-button') const timer = content.getByTestId('record-timer') @@ -51,7 +51,7 @@ test('測試錄製按鈕有否根據設定顯示', async ({ content, context, ta logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByText('隐藏录制按钮').click() // hide the ui await settingsPage.getByText('保存设定').click() @@ -86,14 +86,14 @@ test('測試房間名單列表(黑名單/白名單)', ) ) -test('測試錄製 FLV', async ({ content, page, context, tabUrl }) => { +test('測試錄製 FLV', async ({ content, page, context, optionPageUrl }) => { test.slow() logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByTestId('record-output-type').locator('div > div').nth(0).click() @@ -145,14 +145,14 @@ test('測試錄製 HLS', async ({ content, page }) => { expect(info.relativeDuration()).toBeGreaterThan(30) }) -test('測試熱鍵錄製', async ({ page, tabUrl, context, content }) => { +test('測試熱鍵錄製', async ({ page, optionPageUrl, context, content }) => { test.slow() logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() const input = settingsPage.getByTestId('record-hotkey') @@ -218,14 +218,14 @@ test('測試錄製時長', async ({ content, page }) => { }) -test('測試手動錄製', async ({ content, page, context, tabUrl }) => { +test('測試手動錄製', async ({ content, page, context, optionPageUrl }) => { test.slow() logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByTestId('record-duration').locator('div > div').nth(0).click() @@ -262,7 +262,7 @@ test('測試手動錄製', async ({ content, page, context, tabUrl }) => { }) -test('測試 HLS 完整編譯', async ({ content, page, context, tabUrl }) => { +test('測試 HLS 完整編譯', async ({ content, page, context, optionPageUrl }) => { // I bet 10 mins for this test.setTimeout(600000) @@ -270,7 +270,7 @@ test('測試 HLS 完整編譯', async ({ content, page, context, tabUrl }) => { logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.getByTestId('record-fix').locator('div > div').nth(0).click() @@ -341,7 +341,7 @@ test('測試 HLS 完整編譯', async ({ content, page, context, tabUrl }) => { }) -test('測試 FLV 完整編譯', async ({ content, page, context, tabUrl }) => { +test('測試 FLV 完整編譯', async ({ content, page, context, optionPageUrl }) => { // I bet 10 mins for this test.setTimeout(600000) @@ -349,7 +349,7 @@ test('測試 FLV 完整編譯', async ({ content, page, context, tabUrl }) => { logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() // change to flv first diff --git a/tests/features/superchat.spec.ts b/tests/features/superchat.spec.ts index 48856808..3bac58aa 100644 --- a/tests/features/superchat.spec.ts +++ b/tests/features/superchat.spec.ts @@ -103,14 +103,14 @@ test('測試拖拽按鈕', async ({ content }) => { // dunno how to validate.... }) -test('測試離線記錄醒目留言', async ({ room, content: p, context, tabUrl, page }) => { +test('測試離線記錄醒目留言', async ({ room, content: p, context, optionPageUrl, page }) => { let section = p.locator('bjf-csui section#bjf-feature-superchat') logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.waitForTimeout(1000) await settingsPage.getByText('功能设定').click() @@ -161,7 +161,7 @@ test('測試房間名單列表(黑名單/白名單)', ) ) -test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ content, context, tabUrl }) => { +test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ content, context, optionPageUrl }) => { const button = content.locator('button', { hasText: /^醒目留言$/ }) await expect(button).toBeVisible() @@ -173,7 +173,7 @@ test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ conte logger.info('正在修改設定...') const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() await settingsPage.locator('#features\\.superchat').getByText('在全屏模式下显示').click() // enabled await settingsPage.getByText('保存设定').click() @@ -189,12 +189,12 @@ test('測試全屏時有否根據設定顯示隱藏浮動按鈕', async ({ conte await expect(button).toBeVisible() }) -test('測試保存設定後 css 能否生效', async ({ content, page, tabUrl, context }) => { +test('測試保存設定後 css 能否生效', async ({ content, page, optionPageUrl, context }) => { logger.info('正在修改設定...') const settingsPage = await context.newPage() await settingsPage.bringToFront() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.waitForTimeout(1000) await settingsPage.getByText('功能设定').click() diff --git a/tests/fixtures/extension.ts b/tests/fixtures/extension.ts index 75b0a7e0..088ebdf1 100644 --- a/tests/fixtures/extension.ts +++ b/tests/fixtures/extension.ts @@ -13,6 +13,7 @@ export type ExtensionFixtures = { context: BrowserContext extensionId: string tabUrl: (tab: string) => string + optionPageUrl: string serviceWorker: Worker } @@ -64,6 +65,9 @@ export const extensionBase = base.extend { await use((tab: string) => `chrome-extension://${extensionId}/tabs/${tab}`) }, + optionPageUrl: async ({ extensionId }, use) => { + await use(`chrome-extension://${extensionId}/options.html`) + }, serviceWorker: [ async ({ context }, use) => { logger.info('total service workers: ', context.serviceWorkers().length) diff --git a/tests/pages/settings.spec.ts b/tests/pages/options.spec.ts similarity index 99% rename from tests/pages/settings.spec.ts rename to tests/pages/options.spec.ts index 57ce0ca9..44c07b4d 100644 --- a/tests/pages/settings.spec.ts +++ b/tests/pages/options.spec.ts @@ -6,8 +6,8 @@ import logger from '@tests/helpers/logger' import { getSuperChatList } from '@tests/utils/playwright' import type { MV2Settings } from '~migrations/schema' -test.beforeEach(async ({ page, tabUrl }) => { - await page.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) +test.beforeEach(async ({ page, extensionId }) => { + await page.goto(`chrome-extension://${extensionId}/options.html`, { waitUntil: 'domcontentloaded' }) }) test('測試頁面是否成功加載', async ({ page }) => { diff --git a/tests/utils/playwright.ts b/tests/utils/playwright.ts index 98a25ee2..c884b65d 100644 --- a/tests/utils/playwright.ts +++ b/tests/utils/playwright.ts @@ -65,7 +65,7 @@ export function testFeatureRoomList(feature: string, expect: Expect, lo await expect(locator).toBeVisible() const settingsPage = await context.newPage() - await settingsPage.goto(tabUrl('settings.html'), { waitUntil: 'domcontentloaded' }) + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() const roomInput = settingsPage.getByTestId(feature+'-whitelist-rooms-input') const switcher = settingsPage.getByTestId(feature+'-whitelist-rooms').getByText('使用为黑名单') From ba300608ae43908ff0b1af3b27370252a390cdb2 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sat, 6 Apr 2024 20:41:53 +0800 Subject: [PATCH 09/14] default enabled features changed to only `jimaku` --- src/options/fragments/features.tsx | 2 +- tests/utils/playwright.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/options/fragments/features.tsx b/src/options/fragments/features.tsx index 800b9ca1..c98c6626 100644 --- a/src/options/fragments/features.tsx +++ b/src/options/fragments/features.tsx @@ -29,7 +29,7 @@ export type SettingSchema = { export const defaultSettings: Readonly = { - enabledFeatures: [ 'superchat', 'jimaku' ], + enabledFeatures: [ 'jimaku' ], enabledRecording: [], common: { enabledPip: false, diff --git a/tests/utils/playwright.ts b/tests/utils/playwright.ts index c884b65d..d86d1dc5 100644 --- a/tests/utils/playwright.ts +++ b/tests/utils/playwright.ts @@ -59,7 +59,7 @@ export async function getSuperChatList(section: Locator, options?: { */ export function testFeatureRoomList(feature: string, expect: Expect, locate: (content: PageFrame) => Locator): (args: any) => Promise { - return async ({ room, content, context, tabUrl }) => { + return async ({ room, content, context, optionPageUrl }) => { const locator = locate(content) await expect(locator).toBeVisible() From 42081c1a430790c2b76131c223890cdfa3205378 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sun, 7 Apr 2024 00:21:13 +0800 Subject: [PATCH 10/14] [run ci] added screenshot into recorder feature --- package.json | 1 + playwright.config.ts | 2 +- pnpm-lock.yaml | 7 ++ src/contents/index/mounter.tsx | 7 +- .../recorder/components/RecorderButton.tsx | 22 +++-- .../recorder/components/RecorderLayer.tsx | 45 +++++++-- src/options/components/HotKeyInput.tsx | 2 +- src/options/features/recorder/index.tsx | 66 +++++++++---- src/options/fragments/developer.tsx | 12 +++ src/utils/binary.ts | 12 +++ tests/content.spec.ts | 31 ++++++- tests/features/recorder.spec.ts | 92 ++++++++++++++++++- tests/features/superchat.spec.ts | 23 ++++- tests/pages/options.spec.ts | 3 + tests/utils/file.ts | 29 +++++- 15 files changed, 302 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index c004debb..f0320b24 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "esbuild": "^0.20.2", "gify-parse": "^1.0.7", "glob": "^10.3.10", + "jpeg-js": "^0.4.4", "node-video-lib": "^2.2.3", "postcss": "^8.4.38", "prettier": "^3.2.5", diff --git a/playwright.config.ts b/playwright.config.ts index a61b2352..eb070ec6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,7 @@ dotenv.config({ path: '.env.local' }) * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - timeout: process.env.CI ? 120000 : 30000, + timeout: process.env.CI ? 120000 : 60000, testDir: './tests', /* Run tests in files in parallel */ fullyParallel: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a5544b6..abc9f547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ devDependencies: glob: specifier: ^10.3.10 version: 10.3.10 + jpeg-js: + specifier: ^0.4.4 + version: 0.4.4 node-video-lib: specifier: ^2.2.3 version: 2.2.3 @@ -4920,6 +4923,10 @@ packages: engines: {node: '>=10'} dev: false + /jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} diff --git a/src/contents/index/mounter.tsx b/src/contents/index/mounter.tsx index 515b5339..d4b945de 100644 --- a/src/contents/index/mounter.tsx +++ b/src/contents/index/mounter.tsx @@ -38,10 +38,8 @@ function createMountPoints(plasmo: PlasmoSpec, info: StreamInfo): RootMountable[ const { rootContainer } = plasmo return Object.entries(features).map(([key, handler]) => { - const { default: hook, App, FeatureContext: Context } = handler - - const section = findOrCreateElement('section', `bjf-feature-${key}`, rootContainer) + const { default: hook, App, FeatureContext: Context } = handler const feature = key as FeatureType // this root is feature root let root: Root = null @@ -76,7 +74,7 @@ function createMountPoints(plasmo: PlasmoSpec, info: StreamInfo): RootMountable[ ) }) - + const section = findOrCreateElement('section', `bjf-feature-${key}`, rootContainer) root = createRoot(section) root.render( @@ -94,6 +92,7 @@ function createMountPoints(plasmo: PlasmoSpec, info: StreamInfo): RootMountable[ return } root.unmount() + rootContainer.querySelector(`section#bjf-feature-${key}`)?.remove() } } }) diff --git a/src/features/recorder/components/RecorderButton.tsx b/src/features/recorder/components/RecorderButton.tsx index a44ecf8a..841ab08c 100644 --- a/src/features/recorder/components/RecorderButton.tsx +++ b/src/features/recorder/components/RecorderButton.tsx @@ -2,6 +2,7 @@ import { IconButton, Tooltip } from "@material-tailwind/react" import { useInterval } from "@react-hooks-library/core" import { useContext, useState, type MutableRefObject } from "react" import TailwindScope from "~components/TailwindScope" +import ContentContext from "~contexts/ContentContexts" import RecorderFeatureContext from "~contexts/RecorderFeatureContext" import { useForceRender } from "~hooks/force-update" import { useComputedStyle, useContrast } from "~hooks/styles" @@ -10,18 +11,21 @@ import { toTimer } from "~utils/misc" export type RecorderButtonProps = { recorder: MutableRefObject - onClick?: () => void + record: VoidFunction + screenshot: VoidFunction } function RecorderButton(props: RecorderButtonProps): JSX.Element { const { duration } = useContext(RecorderFeatureContext) - const { recorder, onClick } = props + const { settings } = useContext(ContentContext) + const { recorder, record, screenshot } = props const [timer, setTimer] = useState(0) const [recording, setRecording] = useState(false) const update = useForceRender() - const { backgroundImage } = useComputedStyle(document.getElementById('head-info-vm')) + const { headInfoArea } = settings['settings.developer'].elements + const { backgroundImage } = useComputedStyle(document.querySelector(headInfoArea)) useInterval(() => { if (!recorder.current) return @@ -42,8 +46,14 @@ function RecorderButton(props: RecorderButtonProps): JSX.Element { return ( -
- +
+ + + + + + + {recording ? : @@ -52,7 +62,7 @@ function RecorderButton(props: RecorderButtonProps): JSX.Element { {recording && ( -
+
{toTimer(timer)}
)} diff --git a/src/features/recorder/components/RecorderLayer.tsx b/src/features/recorder/components/RecorderLayer.tsx index 176e7504..8d98b4f6 100644 --- a/src/features/recorder/components/RecorderLayer.tsx +++ b/src/features/recorder/components/RecorderLayer.tsx @@ -17,6 +17,7 @@ import { sendMessager } from "~utils/messaging" import { randomString } from '~utils/misc' import createRecorder from "../recorders" import RecorderButton from "./RecorderButton" +import { screenshotFromVideo } from "~utils/binary" export type RecorderLayerProps = { urls: StreamUrls @@ -64,10 +65,11 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { const { urls } = props const { info, settings } = useContext(ContentContext) - const { elements: { upperHeaderArea } } = settings['settings.developer'] + const { elements: { upperHeaderArea, livePlayerVideo } } = settings['settings.developer'] const { duration, - hotkeyClip, + recordHotkey: recordKey, + screenshotHotkey: screenshotKey, recordFix, mechanism, hiddenUI, @@ -174,7 +176,7 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { success: recordFix === 'reencode' ? '视频已发送到后台进行完整编码。' : '视频下载成功。' }) } - + try { await encoding @@ -190,9 +192,30 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { }, [ffmpeg]) - useKeyDown(hotkeyClip.key, async (e) => { - if (e.ctrlKey !== hotkeyClip.ctrlKey) return - if (e.shiftKey !== hotkeyClip.shiftKey) return + const screenshot = useCallback(() => { + const video = document.querySelector(livePlayerVideo) as HTMLVideoElement + if (video === null) { + toast.warning('截图失败: 无法找到直播视频') + return + } + const screenshoting = (async () => { + const blob = await screenshotFromVideo(video) + const today = new Date().toString().substring(0, 24).replaceAll(' ', '-').replaceAll(':', '-') + const filename = `${info.room}-${today}.jpeg` + downloadBlob(blob, filename) + })(); + toast.promise(screenshoting, { + loading: '正在摄取直播画面...', + error: err => `截图失败: ${err?.message ?? err}`, + success: '截图成功并已保存。' + }) + }, []) + + + useKeyDown(recordKey.key, async (e) => { + if (e.ctrlKey !== recordKey.ctrlKey) return + if (e.shiftKey !== recordKey.shiftKey) return + e.preventDefault() try { await clipRecord() } catch (err: Error | any) { @@ -201,6 +224,13 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { } }) + useKeyDown(screenshotKey.key, (e) => { + if (e.ctrlKey !== screenshotKey.ctrlKey) return + if (e.shiftKey !== screenshotKey.shiftKey) return + e.preventDefault() + screenshot() + }) + if (hiddenUI || document.querySelector(upperHeaderArea) === null) { return null } @@ -208,7 +238,8 @@ function RecorderLayer(props: RecorderLayerProps): JSX.Element { return createPortal( , document.querySelector(upperHeaderArea) ) diff --git a/src/options/components/HotKeyInput.tsx b/src/options/components/HotKeyInput.tsx index 62cf4d2c..3564712f 100644 --- a/src/options/components/HotKeyInput.tsx +++ b/src/options/components/HotKeyInput.tsx @@ -19,7 +19,7 @@ export function HotKeyInput(props: HotKeyInputProps): JSX.Element { const { optional: opt, value, onChange, ...attrs } = props const optional = opt ?? true - const [ listening, setListening ] = useState(false) + const [listening, setListening] = useState(false) const onListenKeyInput = () => { const listener = (e: KeyboardEvent) => { diff --git a/src/options/features/recorder/index.tsx b/src/options/features/recorder/index.tsx index 16deff9c..24af15fe 100644 --- a/src/options/features/recorder/index.tsx +++ b/src/options/features/recorder/index.tsx @@ -1,12 +1,11 @@ -import { List, Switch, Typography } from "@material-tailwind/react" +import { Switch, Typography } from "@material-tailwind/react" import { Fragment, type ChangeEvent } from "react" import type { RecorderType } from "~features/recorder/recorders" import type { StateProxy } from "~hooks/binding" import { HotKeyInput, type HotKey } from "~options/components/HotKeyInput" import Selector from "~options/components/Selector" -import type { FeatureSettingsDefinition } from ".." -import SwitchListItem from "~options/components/SwitchListItem" import type { PlayerType } from "~players" +import type { FeatureSettingsDefinition } from ".." import { toast } from "sonner/dist" export const title: string = '快速切片' @@ -19,7 +18,8 @@ export type FeatureSettingSchema = { duration: number outputType?: PlayerType recordFix: 'copy' | 'reencode' - hotkeyClip: HotKey + recordHotkey: HotKey + screenshotHotkey: HotKey mechanism: RecorderType hiddenUI: boolean threads: number @@ -30,11 +30,16 @@ export const defaultSettings: Readonly = { duration: 5, outputType: 'hls', recordFix: 'copy', - hotkeyClip: { + recordHotkey: { key: 'x', ctrlKey: true, shiftKey: false, }, + screenshotHotkey: { + key: 'v', + ctrlKey: true, + shiftKey: false, + }, mechanism: 'buffer', hiddenUI: false, threads: 0.5, @@ -43,10 +48,15 @@ export const defaultSettings: Readonly = { export function RecorderFeatureSettings({ state, useHandler }: StateProxy): JSX.Element { - const onChangeHotKey = (key: HotKey) => { - state.hotkeyClip.key = key.key - state.hotkeyClip.ctrlKey = key.ctrlKey - state.hotkeyClip.shiftKey = key.shiftKey + const validateKeyInConflict = (key: HotKey, type: 'record' | 'screenshot') => { + const inputKey = key.key.toLowerCase() + const currentKey = type === 'record' ? state.screenshotHotkey.key.toLowerCase() : state.recordHotkey.key.toLowerCase() + console.debug('inputKey:', inputKey, 'currentKey:', currentKey) + if (inputKey === currentKey) { + toast.error('热键冲突: 快速切片热键与截图热键的主键不能相同。') + return true + } + return false } const bool = useHandler, boolean>(e => e.target.checked) @@ -100,7 +110,7 @@ export function RecorderFeatureSettings({ state, useHandler }: StateProxy - + data-testid="record-overflow" label="超出 2GB 大小时" value={state.overflow} @@ -110,14 +120,6 @@ export function RecorderFeatureSettings({ state, useHandler }: StateProxy -
- -
- 隐藏录制按钮 + 隐藏界面按钮 隐藏后,你仍可通过热键使用本功能。 @@ -136,6 +138,32 @@ export function RecorderFeatureSettings({ state, useHandler }: StateProxy
+
+ { + if (validateKeyInConflict(key, 'record')) return + state.recordHotkey.key = key.key + state.recordHotkey.ctrlKey = key.ctrlKey + state.recordHotkey.shiftKey = key.shiftKey + }} + /> +
+
+ { + if (validateKeyInConflict(key, 'screenshot')) return + state.screenshotHotkey.key = key.key + state.screenshotHotkey.ctrlKey = key.ctrlKey + state.screenshotHotkey.shiftKey = key.shiftKey + }} + /> +
) } diff --git a/src/options/fragments/developer.tsx b/src/options/fragments/developer.tsx index d2e2ddec..4b649ff1 100644 --- a/src/options/fragments/developer.tsx +++ b/src/options/fragments/developer.tsx @@ -8,6 +8,7 @@ import { setSettingStorage } from '~utils/storage'; export type SettingSchema = { elements: { + headInfoArea: string; upperHeaderArea: string; upperButtonArea: string; webPlayer: string; @@ -16,6 +17,7 @@ export type SettingSchema = { jimakuArea: string; jimakuFullArea: string; videoArea: string; + livePlayerVideo: string; liveTitle: string; chatItems: string; newMsgButton: string; @@ -35,6 +37,7 @@ export type SettingSchema = { export const defaultSettings: Readonly = { elements: { + headInfoArea: '#head-info-vm', // 直播头部元素 upperHeaderArea: 'div.upper-row > div.left-ctnr.left-header-area', // 上方标题界面元素 upperButtonArea: '.lower-row .left-ctnr', // 上方按钮界面元素 webPlayer: '.web-player-danmaku', // 播放器元素 @@ -43,6 +46,7 @@ export const defaultSettings: Readonly = { jimakuArea: 'div.player-section', // 字幕区块全屏插入元素 jimakuFullArea: '.web-player-inject-wrap', // 字幕区块非全屏插入元素 videoArea: 'div#aside-area-vm', // 直播屏幕区块 + livePlayerVideo: '#live-player video', // 直播视频元素 liveTitle: '.live-skin-main-text.small-title', // 直播标题 chatItems: '#chat-items', // 直播聊天栏列表 newMsgButton: 'div#danmaku-buffer-prompt', // 新消息按钮 @@ -81,6 +85,10 @@ type ElementDefinerList = { const elementDefiners: ElementDefinerList = { "元素捕捉": [ + { + label: "直播头部元素", + key: "elements.headInfoArea" + }, { label: "上方标题界面元素", key: "elements.upperHeaderArea" @@ -113,6 +121,10 @@ const elementDefiners: ElementDefinerList = { label: "直播屏幕区块", key: "elements.videoArea" }, + { + label: "直播视频元素", + key: "elements.livePlayerVideo" + }, { label: "直播标题", key: "elements.liveTitle" diff --git a/src/utils/binary.ts b/src/utils/binary.ts index bd6c4efb..ceb4b564 100644 --- a/src/utils/binary.ts +++ b/src/utils/binary.ts @@ -82,4 +82,16 @@ export async function serializeBlobAsNumbers(blob: Blob): Promise { export function deserializeNumbersToBlob(numbers: number[]): Blob { const buffer = new Uint8Array(numbers) return new Blob([buffer]) +} + +export async function screenshotFromVideo(media: HTMLVideoElement): Promise { + const canvas = document.createElement('canvas') + canvas.width = media.videoWidth + canvas.height = media.videoHeight + const ctx = canvas.getContext('2d') + ctx.drawImage(media, 0, 0, canvas.width, canvas.height) + return new Promise((res, rej) => { + canvas.toBlob(res, 'image/jpeg') + canvas.onerror = rej + }) } \ No newline at end of file diff --git a/tests/content.spec.ts b/tests/content.spec.ts index cc0c304d..3db45ddb 100644 --- a/tests/content.spec.ts +++ b/tests/content.spec.ts @@ -12,7 +12,30 @@ test('測試主元素是否存在', async ({ content }) => { await expect(csui.locator('#bjf-root')).toBeAttached() }) -test('测试扩展CSS有否影响到外围', async ({ content, room, isThemeRoom }) => { +test('測試功能元素有否基於設定而消失/顯示', async ({ content, context, optionPageUrl }) => { + + // 默認只開了同傳字幕 + const csui = content.locator('bjf-csui') + await csui.waitFor({ state: 'attached', timeout: 10000 }) + + await expect(csui.locator('section#bjf-feature-jimaku')).toBeAttached() + await expect(csui.locator('section#bjf-feature-recorder')).not.toBeAttached() + await expect(csui.locator('section#bjf-feature-superchat')).not.toBeAttached() + + logger.info('正在修改設定') + const settingsPage = await context.newPage() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + await settingsPage.getByText('启用快速切片').click() + await settingsPage.getByText('启用醒目留言').click() + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + await expect(csui.locator('section#bjf-feature-recorder')).toBeAttached() + await expect(csui.locator('section#bjf-feature-superchat')).toBeAttached() +}) + +test('测试扩展CSS有否影响到外围', async ({ content, isThemeRoom }) => { test.skip(isThemeRoom, '此測試不適用於大海報房間') @@ -212,7 +235,7 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content 'media-chrome-button#danmaku-btn', // custom button 'media-chrome-button#reload-btn' // custom button ] - + await monitor.locator('media-control-bar').hover() for (const button of buttons) { const locator = monitor.locator(button) @@ -220,14 +243,14 @@ test('測試弹出直播视窗按鈕', async ({ context, optionPageUrl, content } // Test custom buttons - + // danmaku button await monitor.locator('media-control-bar').hover() await monitor.locator('#danmaku-btn').click() await expect(monitor.locator('.N-dmLayer')).toHaveCSS('display', 'none') await monitor.locator('#danmaku-btn').click() await expect(monitor.locator('.N-dmLayer')).toHaveCSS('display', 'block') - + // reload button await monitor.locator('media-control-bar').hover() const reload = monitor.waitForEvent('load') diff --git a/tests/features/recorder.spec.ts b/tests/features/recorder.spec.ts index ef6fcf87..fb27cadb 100644 --- a/tests/features/recorder.spec.ts +++ b/tests/features/recorder.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@tests/fixtures/content" import logger from "@tests/helpers/logger" -import { readMovieInfo } from "@tests/utils/file" +import { readJpeg, readMovieInfo } from "@tests/utils/file" import { testFeatureRoomList } from "@tests/utils/playwright" import fs from 'fs/promises' @@ -40,7 +40,18 @@ test('測試功能元素是否存在', async ({ content }) => { }) -test('測試錄製按鈕有否根據設定顯示', async ({ content, context, optionPageUrl }) => { +test('測試界面元素是否存在', async ({ content }) => { + + const button = content.getByTestId('record-button') + const timer = content.getByTestId('record-timer') + const screenshot = content.getByTestId('screenshot-button') + + await expect(button).toBeVisible() + await expect(timer).toBeVisible() + await expect(screenshot).toBeVisible() +}) + +test('測試界面按鈕有否根據設定顯示', async ({ content, context, optionPageUrl }) => { const button = content.getByTestId('record-button') const timer = content.getByTestId('record-timer') @@ -53,7 +64,7 @@ test('測試錄製按鈕有否根據設定顯示', async ({ content, context, op await settingsPage.bringToFront() await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) await settingsPage.getByText('功能设定').click() - await settingsPage.getByText('隐藏录制按钮').click() // hide the ui + await settingsPage.getByText('隐藏界面按钮').click() // hide the ui await settingsPage.getByText('保存设定').click() await settingsPage.close() @@ -86,6 +97,29 @@ test('測試房間名單列表(黑名單/白名單)', ) ) +test('測試截圖', async ({ content, page }) => { + + const button = content.getByTestId('screenshot-button') + const download = page.waitForEvent('download') + + await button.click() + await expect(content.getByText('截图成功并已保存')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.jpeg')).toBeTruthy() + await downloaded.saveAs('out/screenshot.jpeg') + + // 默认超清画质 + const info = await readJpeg('out/screenshot.jpeg') + logger.info('图片信息:', info) + + expect(info.width).toBeLessThanOrEqual(1920) + expect(info.height).toBeLessThanOrEqual(1080) + + expect(info.width).toBeGreaterThanOrEqual(854) + expect(info.height).toBeGreaterThanOrEqual(480) +}) + test('測試錄製 FLV', async ({ content, page, context, optionPageUrl }) => { test.slow() @@ -156,6 +190,14 @@ test('測試熱鍵錄製', async ({ page, optionPageUrl, context, content }) => await settingsPage.getByText('功能设定').click() const input = settingsPage.getByTestId('record-hotkey') + + logger.info('正在測試按鍵衝突阻止...') + await input.click() + await expect(input).toHaveValue('监听输入中...') + await input.press('V') + await expect(settingsPage.getByText('热键冲突')).toBeVisible() + + logger.info('正在測試修改熱鍵...') await input.click() await expect(input).toHaveValue('监听输入中...') await input.press('Control+Shift+R') @@ -181,6 +223,50 @@ test('測試熱鍵錄製', async ({ page, optionPageUrl, context, content }) => expect(info.relativeDuration()).toBeGreaterThan(30) }) +test('測試熱鍵截圖', async ({ page, content, context, optionPageUrl }) => { + + logger.info('正在修改設定...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + + const input = settingsPage.getByTestId('screenshot-hotkey') + + logger.info('正在測試按鍵衝突阻止...') + await input.click() + await expect(input).toHaveValue('监听输入中...') + await input.press('X') + await expect(settingsPage.getByText('热键冲突')).toBeVisible() + + logger.info('正在測試修改熱鍵...') + await input.click() + await expect(input).toHaveValue('监听输入中...') + await input.press('Control+Shift+V') + await expect(input).toHaveValue('Ctrl+Shift+V') + + await settingsPage.getByText('保存设定').click() + await settingsPage.close() + + await content.getByTestId('screenshot-button').waitFor({ state: 'visible' }) + const download = page.waitForEvent('download') + await page.locator('body').press('Control+Shift+V') + await expect(content.getByText('截图成功并已保存')).toBeVisible() + + const downloaded = await download + expect(() => downloaded.suggestedFilename().endsWith('.jpeg')).toBeTruthy() + await downloaded.saveAs('out/screenshot.jpeg') + + const info = await readJpeg('out/screenshot.jpeg') + logger.info('图片信息:', info) + + expect(info.width).toBeLessThanOrEqual(1920) + expect(info.height).toBeLessThanOrEqual(1080) + + expect(info.width).toBeGreaterThanOrEqual(854) + expect(info.height).toBeGreaterThanOrEqual(480) +}) + test('測試錄製時長', async ({ content, page }) => { diff --git a/tests/features/superchat.spec.ts b/tests/features/superchat.spec.ts index 3bac58aa..a037fb5a 100644 --- a/tests/features/superchat.spec.ts +++ b/tests/features/superchat.spec.ts @@ -3,9 +3,26 @@ import logger from '@tests/helpers/logger' import { readText } from '@tests/utils/file' import { getSuperChatList, testFeatureRoomList } from '@tests/utils/playwright' -test.beforeEach(async ({ page }) => { - logger.info('正在等待登入彈窗消失...') - await page.waitForTimeout(7000) +test.beforeEach(async ({ content, context, optionPageUrl, isThemeRoom }) => { + + if (isThemeRoom) { + test.slow() + await content.getByText('挂接成功').waitFor({ + state: 'visible', + timeout: 60000 + }) + } + + + logger.info('正在啟用功能...') + const settingsPage = await context.newPage() + await settingsPage.bringToFront() + await settingsPage.goto(optionPageUrl, { waitUntil: 'domcontentloaded' }) + await settingsPage.getByText('功能设定').click() + await settingsPage.getByText('启用醒目留言').click() + await settingsPage.getByText("保存设定").click() + await settingsPage.getByText("所有设定已经保存成功。").waitFor({ state: 'visible' }) + await settingsPage.close() }) test('測試功能元素是否存在', async ({ content }) => { diff --git a/tests/pages/options.spec.ts b/tests/pages/options.spec.ts index 44c07b4d..dbcc8f42 100644 --- a/tests/pages/options.spec.ts +++ b/tests/pages/options.spec.ts @@ -152,6 +152,9 @@ test('測試清空數據庫', async ({ page, front: room, api }) => { await page.bringToFront() const feature = page.getByText('功能设定') await feature.click() + + await page.getByText('启用醒目留言').click() // default is disabled + const btns = await page.locator('section#settings\\.features').getByText('启用离线记录').all() for (const btn of btns) { await btn.click() diff --git a/tests/utils/file.ts b/tests/utils/file.ts index aa11cc71..31d59ca0 100644 --- a/tests/utils/file.ts +++ b/tests/utils/file.ts @@ -5,8 +5,10 @@ import fs from 'fs/promises' import type { PathLike } from 'fs' import VideoLib from 'node-video-lib' import gifyParse from 'gify-parse' +import jpeg from 'jpeg-js' import type { Movie } from '@tests/types/movie' + export type IModule = { name: string, file: string, @@ -61,14 +63,22 @@ export async function readText(readable: Readable): Promise { }) } - - +/** + * Reads movie information from a file or buffer. + * @param source - The path to the file or the buffer containing the movie information. + * @returns A promise that resolves to the parsed movie information. + */ export async function readMovieInfo(source: PathLike | Buffer): Promise { source = await readBufferIfNeeded(source) return VideoLib.MovieParser.parse(source) } - +/** + * Reads the GIF information from the given source. + * + * @param source - The path or buffer containing the GIF data. + * @returns A promise that resolves to the GIF information. + */ export async function readGifInfo(source: PathLike | Buffer) { source = await readBufferIfNeeded(source) return gifyParse.getInfo(source); @@ -76,4 +86,15 @@ export async function readGifInfo(source: PathLike | Buffer) { async function readBufferIfNeeded(source: PathLike | Buffer): Promise { return Buffer.isBuffer(source) ? source : fs.readFile(source) -} \ No newline at end of file +} + +/** + * Reads a JPEG file from the specified path and decodes it. + * @param path - The path to the JPEG file. + * @returns A Promise that resolves to the decoded JPEG data. + */ +export async function readJpeg(path: string) { + const data = await fs.readFile(path) + return jpeg.decode(data) +} + From de04a16ee0ac8a96940b5189574e042584d6101e Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sun, 7 Apr 2024 12:55:05 +0800 Subject: [PATCH 11/14] changed the location of button switch list --- src/features/jimaku/components/ButtonArea.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/jimaku/components/ButtonArea.tsx b/src/features/jimaku/components/ButtonArea.tsx index 74b9b8c3..b5d6169b 100644 --- a/src/features/jimaku/components/ButtonArea.tsx +++ b/src/features/jimaku/components/ButtonArea.tsx @@ -21,7 +21,7 @@ function ButtonArea({ clearJimaku, jimakus }: ButtonAreaProps): JSX.Element { const { order } = jimakuZone const { enabledRecording } = settings["settings.features"] - const { elements: { upperButtonArea } } = settings['settings.developer'] + const { elements: { upperHeaderArea } } = settings['settings.developer'] const { createPopupWindow } = usePopupWindow(true, { width: 500 }) @@ -61,11 +61,11 @@ function ButtonArea({ clearJimaku, jimakus }: ButtonAreaProps): JSX.Element { }
)} - {info.isTheme && document.querySelector(upperButtonArea) !== null && createPortal( + {info.isTheme && document.querySelector(upperHeaderArea) !== null && createPortal( setShow(!show)} /> , - document.querySelector(upperButtonArea) + document.querySelector(upperHeaderArea) )} ) From b3f1c470069b038d4cc85c30452d591a18c02b66 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sun, 7 Apr 2024 13:54:55 +0800 Subject: [PATCH 12/14] changed default suprechat floating button position from left to right --- src/components/DraggableFloatingButton.tsx | 25 ++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/DraggableFloatingButton.tsx b/src/components/DraggableFloatingButton.tsx index a79ed804..d163ed5d 100644 --- a/src/components/DraggableFloatingButton.tsx +++ b/src/components/DraggableFloatingButton.tsx @@ -24,6 +24,16 @@ export type DraggableFloatingButtonProps = { * Inline styles for the button. */ style?: React.CSSProperties; + + /** + * The initial X position of the button. + */ + initX?: number; + + /** + * The initial Y position of the button. + */ + initY?: number; } @@ -45,11 +55,18 @@ export type DraggableFloatingButtonProps = { */ function DraggableFloatingButton(props: DraggableFloatingButtonProps): JSX.Element { - const { onClick, children, className, style } = props + const { + onClick, + children, + className, + style, + initX = window.innerWidth - 500, + initY = 96 + } = props const colorClass = className ?? 'bg-red-600 duration-150 hover:bg-red-700 dark:bg-gray-700 dark:hover:bg-gray-800 text-white' - const [position, setPosition] = useState({ x: 48, y: 96 }) + const [position, setPosition] = useState({ x: initX, y: initY }) const pos = useDeferredValue(position) return ( @@ -72,8 +89,8 @@ function DraggableFloatingButton(props: DraggableFloatingButtonProps): JSX.Eleme className="rounded-full fixed" onDrag={(_, d) => setPosition({ x: (d.x - 60), y: (d.y - 5) })} default={{ - x: 108, - y: 101, + x: initX+60, + y: initY+5, width: 25, height: 25, }} From c827fa7854c856fbc01ffc75bcda33e5402585d6 Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sun, 7 Apr 2024 14:39:57 +0800 Subject: [PATCH 13/14] updated version and readme.md --- README.md | 9 +++------ package.json | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fd399884..9185968c 100644 --- a/README.md +++ b/README.md @@ -41,21 +41,18 @@ 目前实装功能有: - 同传字幕 - 醒目留言 +- 即时录制/截图 ... 以及其他 bilibili-jimaku-filter 原有的功能 - -预计添加的功能: -- 即时录制/截图 -- 高亮用户弹幕/进入直播间 -- 开播提醒 - ## ➵ 使用方式 1. [下载](#-下载)本扩展。 2. 点击扩展图标进入设定页面,并根据你的偏好进入设定。完成后,然后按下保存设定。 3. 进入B站任一直播间即可开始使用。 +详情可参阅 [使用指南](https://cdn.jsdelivr.net/gh/eric2788/bilibili-vup-stream-enhancer@web/tutorials/index.md) + ## ➵ 贡献 请参阅 [贡献指南](CONTRIBUTING.md)。 diff --git a/package.json b/package.json index f0320b24..b0386489 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bilibili-vup-stream-enhancer", "displayName": "Bilibili Vup Stream Enhancer", - "version": "2.0.1", + "version": "2.0.2", "description": "管人观众专用直播增强扩展", "author": "Eric Lam ", "license": "MIT", From 4c3f64e55c2cbc872dc7a17923d2b5104f58329b Mon Sep 17 00:00:00 2001 From: eric2788 Date: Sun, 7 Apr 2024 16:37:34 +0800 Subject: [PATCH 14/14] Updated pnpm/action-setup to version 3 in build-test.yml, partial-test.yml, and publish.yml workflows --- .github/workflows/build-test.yml | 6 +++--- .github/workflows/partial-test.yml | 4 ++-- .github/workflows/publish.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 5476918c..a6fa71ab 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -37,7 +37,7 @@ jobs: browser: [chrome, edge, chromium] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: 8 - name: Setup Node.js @@ -67,7 +67,7 @@ jobs: - uses: actions/checkout@v4 - name: Ensure No @Scoped Test run: grep -r --include "*.spec.ts" "@scoped" && echo "please remove @scoped from tests" && exit 1 || echo "No @scoped tests found" - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: 8 - name: Setup Node.js @@ -123,7 +123,7 @@ jobs: path: build/ - name: Unzip artifacts run: unzip build/${{ matrix.browser }}-mv3-prod.zip -d build/extension - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: 8 - name: Setup Node.js diff --git a/.github/workflows/partial-test.yml b/.github/workflows/partial-test.yml index 75be8927..39b25b73 100644 --- a/.github/workflows/partial-test.yml +++ b/.github/workflows/partial-test.yml @@ -26,7 +26,7 @@ jobs: project: [units, integrations] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: 8 - name: Setup Node.js @@ -63,7 +63,7 @@ jobs: browser: [chrome, edge] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: 8 - name: Setup Node.js diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2853d695..470c646b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: browser: [chrome, edge] steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v3 with: version: 8 - name: Setup Node.js