Merge remote-tracking branch 'main/preview-features' into update-to-vue3

This commit is contained in:
timongh 2023-03-11 21:19:43 +08:00
commit da48523257
70 changed files with 2154 additions and 1511 deletions

4
.vscode/tasks.json vendored
View File

@ -68,14 +68,14 @@
},
{
"type": "shell",
"command": "pnpm eslint --cache --cache-location node_modules/.cache/eslint/ './**/*.@(js|ts|vue)'",
"command": "pnpm eslint . --cache --cache-location node_modules/.cache/eslint/ --ext .ts,.vue",
"group": "build",
"problemMatcher": [],
"label": "生产:代码检查 prod:lint"
},
{
"type": "shell",
"command": "pnpm eslint --cache --cache-location node_modules/.cache/eslint/ --fix './**/*.@(js|ts|vue)'",
"command": "pnpm eslint . --cache --cache-location node_modules/.cache/eslint/ --fix --ext .ts,.vue",
"group": "build",
"problemMatcher": [],
"label": "生产:代码修复 prod:lint-fix"

View File

@ -1,5 +1,93 @@
# 更新日志
## v2.6.3-preview
`2023-02-09`
包含 v2.6.3 的所有更新内容.
- 启用新的代码加载方式. (PR #3938 by [timongh](https://github.com/timongh))
## v2.6.3
`2023-02-20`
- 修复 `隐藏推荐直播` 失效. (#3993)
- 修复 `夜间模式` 下鼠标移至特定页排行榜时的显示问题. (#1052, PR #4001 by [Tinhone](https://github.com/Tinhone))
## v2.6.2-preview
`2023-02-09`
包含 v2.6.2 的所有更新内容.
- 修复搜索历史无法删除. (#3893)
## v2.6.2
`2023-02-09`
- 修复 `播放前显示封面` 未正确清除封面. (#3974, PR #3976 by [imshixin](https://github.com/imshixin))
- 删除 NavbarWatchlater.vue 中的未定义属性. (#3949)
## v2.6.1-preview
`2023-02-07`
包含 v2.6.1 的所有更新内容.
- 提高了 `自定义顶栏` 搜索栏中搜索 av 号 / BV 号结果的优先级, 输入编号后直接回车就可以前往视频.
- 添加跨域存储功能 (`crossOriginLocalStorage`), 支持搜索历史跨域同步. (#3893)
- 新增功能 `弹幕转义`. (#3470)
> 将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
## v2.6.1
`2023-02-07`
✨新增
- 优化 `简化评论区` 的时间样式. (#3541, PR #3791 by [Tinhone](https://github.com/Tinhone))
- `下载视频`:
- 支持选择备用下载地址. (PR #3798 by [Seaward233](https://github.com/Seaward233))
- 新增 `StreamSaver` 输出方式.
- 新增 `Motrix` 输出方式. (PR #3908 by [FoundTheWOUT](https://github.com/FoundTheWOUT))
- `删除广告` 支持屏蔽动态的商品推荐. (#3778)
- 优化 `自定义顶栏` 的弹窗逻辑, *有任意元素聚焦时* -> *仅在输入框聚焦时*显示弹窗. (#3756)
- `展开弹幕列表` 新增 `有选集时不自动展开` 选项. (#1734)
- 新增功能 `禁止滚轮调音量`. (#3489)
> 在网页全屏 / 全屏模式下, 禁止鼠标滚轮控制播放器的音量.
- 新增功能 `批量导入合集`. (PR #3884 by [swhoro](https://github.com/swhoro))
> 在合集页面制作一个批量导入按钮,可以新增所有合集内视频至同名收藏夹
- `扩展倍速` 添加 `隐藏移除图标``隐藏新增图标` 选项. (PR #3864 by [Tinhone](https://github.com/Tinhone))
- 新增插件 `快捷键扩展 - 开关 CC 字幕`, 默认为 <kbd>Shift</kbd> + <kbd>C</kbd>. (#3407)
- 新增插件 `快捷键扩展 - 开关弹幕列表`, 默认为 <kbd>Shift</kbd> + <kbd>D</kbd>. (#3613)
- `新版本提示` 支持在安装前查看更新说明.
- 功能面板为空时增加引导提示. (#3932, PR #3936 by [Ethkuil](https://github.com/Ethkuil))
- `下载字幕` 支持在番剧页面使用.
- 新增功能 `禁用评论区搜索词`. (#3648)
> 禁用评论区的搜索词链接.
🐛修复
- `下载视频``flv` 格式恢复 1080P 支持. (一共就 360P / 1080P, 其他清晰度还是没有, 需要的话请使用 dash 格式)
- 修复评论区功能在新版播放页上的支持, 包括 `复制评论链接` / `简化评论区` 等. (#3807)
- 修复 `清爽首页` 的排行榜第三项数据不正确. (#3911, PR #3912 by [Colerar](https://github.com/Colerar))
- 修复 `简化直播间` 对左下角红包的简化样式. (#3243)
- MDI 图标库内联至本体中, 避免阻塞页面加载. (#3964, #3888)
- 修复 `侧栏垂直偏移` 在部分直播间失效的问题. (#3934)
- 修复 `自定义顶栏` 的收藏夹在加载完毕后仍显示加载中. (#3940)
- 修复 `展开弹幕列表` 检测到的弹幕量不正确. (获取页面的已装载弹幕量太坑了, 改为调 API 获取总弹幕量来判断了) (#3958)
- 修复 `启用弹幕空降` 失效. (#3187)
- 修复 `高分辨率图片` 在个人空间投稿页失效. (#3894)
- 修复 `复制评论链接` 在个人空间复制出的链接不正确. (#3821)
- 修复 ASS 弹幕中的颜色标记不正确. (#3960)
☕开发者相关
- 完成 define API + SwitchOptions API 的改造 (受影响组件为 `简化首页``简化直播间`), 感谢 [timongh](https://github.com/timongh) 的长期贡献.
- 重新拆分了一下 PlayerAgent 的文件.
- 修复 CI 构建脚本未提交新增文件. (#3903)
- 新增 Dialog API, 可用简单封装以弹窗形式展示的内容.
- 开源许可有所变更, 对再分发有一些限制, 详见 [LICENSE](./LICENCE.md).
- 删除了 `<template>` 元素多余的 class. (#3951)
- 修复 VSCode Task 中的 ESLint 命令和 package.json 中不一致. (#3950)
- TypeScript 升级至 4.9 版本.
## v2.6.0-preview
`2023-01-01`

View File

@ -28,6 +28,13 @@ https://afdian.net/@the1812?tab=sponsor
| 时间 | 用户名 | 单号后4位 | 金额 |
| ------------------- | ---------------- | --------- | ------- |
| 2023.02.18 22:49:21 | \*😕\* | 9195 | ¥5.00 |
| 2023.02.14 20:40:45 | 匿名 | 9914 | ¥5.00 |
| 2023.02.13 12:33:00 | A*l | 2182 | ¥66.00 |
| 2023.02.08 16:25:37 | 匿名 | 2858 | ¥10.00 |
| 2023.02.08 12:06:19 | 匿名 | 8525 | ¥5.00 |
| 2023.02.03 23:32:39 | J*i | 6870 | ¥10.00 |
| 2023.01.02 13:13:53 | P* | 0764 | ¥5.00 |
| 2022.12.31 16:59:32 | *梦 | 6802 | ¥5.00 |
| 2022.12.26 22:36:34 | b*8 | 3849 | ¥2.50 |
| 2022.12.16 12:27:57 | *訪 | 6143 | ¥3.79 |

View File

@ -415,6 +415,14 @@
"fullRelativePath": "../../registry/dist/components/utils/comments/copy-link.js",
"fullAbsolutePath": "registry/dist/components/utils/comments/copy-link.js"
},
{
"type": "component",
"name": "disableCommentsSearchLink",
"displayName": "禁用评论区搜索词",
"description": "禁用评论区的搜索词链接.",
"fullRelativePath": "../../registry/dist/components/utils/comments/disable-search-link.js",
"fullAbsolutePath": "registry/dist/components/utils/comments/disable-search-link.js"
},
{
"type": "component",
"name": "devClient",
@ -547,10 +555,18 @@
"type": "component",
"name": "expandDanmakuList",
"displayName": "展开弹幕列表",
"description": "每次加载视频时自动展开弹幕列表, 如果弹幕装载量超过 `最大弹幕数量`, 则不展开以免对页面造成卡顿.",
"description": "每次加载视频时自动展开弹幕列表, 如果弹幕装载量超过 `最大弹幕数量`, 则不展开以免对页面造成卡顿. (`最大弹幕数量` 是和视频的总弹幕量对比, 不一定和播放器实际装载的弹幕量相等)\r\n",
"fullRelativePath": "../../registry/dist/components/video/danmaku/expand.js",
"fullAbsolutePath": "registry/dist/components/video/danmaku/expand.js"
},
{
"type": "component",
"name": "unescapeDanmaku",
"displayName": "弹幕转义",
"description": "将弹幕中的 `\\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.\r\n",
"fullRelativePath": "../../registry/dist/components/video/danmaku/unescape.js",
"fullAbsolutePath": "registry/dist/components/video/danmaku/unescape.js"
},
{
"type": "component",
"name": "videoDefaultLocation",
@ -856,6 +872,14 @@
"fullRelativePath": "../../registry/dist/plugins/video/download/manual-input.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/manual-input.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.motrix",
"displayName": "下载视频 - Motrix 输出支持",
"description": "为下载视频增加 Motrix 输出支持.",
"fullRelativePath": "../../registry/dist/plugins/video/download/motrix-output.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/motrix-output.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.mpv-playlist",

View File

@ -505,6 +505,15 @@ by [@snowraincloud](https://github.com/snowraincloud)
开启后, 可在每条评论的菜单中选择复制链接.
### [禁用评论区搜索词](../../registry/dist/components/utils/comments/disable-search-link.js)
`disableCommentsSearchLink`
**jsDelivr:** [`Stable`](https://fastly.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/comments/disable-search-link.js) / [`Preview`](https://fastly.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/comments/disable-search-link.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/comments/disable-search-link.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/comments/disable-search-link.js)
禁用评论区的搜索词链接.
### [DevClient](../../registry/dist/components/utils/dev-client.js)
`devClient`
@ -687,7 +696,16 @@ by [@kdxcxs](https://github.com/kdxcxs)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/danmaku/expand.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/danmaku/expand.js)
每次加载视频时自动展开弹幕列表, 如果弹幕装载量超过 `最大弹幕数量`, 则不展开以免对页面造成卡顿.
每次加载视频时自动展开弹幕列表, 如果弹幕装载量超过 `最大弹幕数量`, 则不展开以免对页面造成卡顿. (`最大弹幕数量` 是和视频的总弹幕量对比, 不一定和播放器实际装载的弹幕量相等)
### [弹幕转义](../../registry/dist/components/video/danmaku/unescape.js)
`unescapeDanmaku`
**jsDelivr:** [`Stable`](https://fastly.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/danmaku/unescape.js) / [`Preview`](https://fastly.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/video/danmaku/unescape.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/danmaku/unescape.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/danmaku/unescape.js)
将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
### [视频页默认定位](../../registry/dist/components/video/default-location.js)
`videoDefaultLocation`
@ -1114,6 +1132,15 @@ by FoundTheWOUT
为下载视频增加手动输入支持.
### [下载视频 - Motrix 输出支持](../../registry/dist/plugins/video/download/motrix-output.js)
`downloadVideo.outputs.motrix`
**jsDelivr:** [`Stable`](https://fastly.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/download/motrix-output.js) / [`Preview`](https://fastly.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/video/download/motrix-output.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/video/download/motrix-output.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/video/download/motrix-output.js)
为下载视频增加 Motrix 输出支持.
### [下载视频 - MPV 播放支持(列表)](../../registry/dist/plugins/video/download/mpv-output-playlist.js)
`downloadVideo.outputs.mpv-playlist`

View File

@ -13,14 +13,14 @@
"type": "tsc -p tsconfig.type-check.json --noEmit"
},
"devDependencies": {
"@babel/core": "7.17.9",
"@babel/parser": "^7.18.5",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.6",
"@babel/preset-typescript": "^7.9.0",
"@babel/types": "^7.18.4",
"@babel/core": "7.20.12",
"@babel/parser": "^7.20.15",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.18.6",
"@babel/types": "^7.20.7",
"@types/async-exit-hook": "^2.0.0",
"@types/babel__core": "^7.1.19",
"@types/babel__core": "^7.20.0",
"@types/color": "^3.0.1",
"@types/glob": "^7.2.0",
"@types/lodash": "^4.14.172",
@ -31,8 +31,8 @@
"@types/streamsaver": "^2.0.1",
"@types/webpack-env": "^1.16.4",
"@types/ws": "^8.2.3",
"@typescript-eslint/eslint-plugin": "^5.9.1",
"@typescript-eslint/parser": "^5.9.1",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
"@vue/compiler-sfc": "^3.2.47",
"@vue/tsconfig": "^0.1.3",
"async-exit-hook": "^2.0.1",
@ -42,7 +42,7 @@
"css-loader": "^5.0.0",
"eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.1.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "7.1.0",
@ -50,7 +50,7 @@
"postcss": "^8.1.0",
"postcss-loader": "^4.0.4",
"postcss-preset-env": "^7.5.0",
"prettier": "^2.7.1",
"prettier": "^2.8.3",
"sass": "^1.25.0",
"serve": "^13.0.2",
"serve-handler": "^6.1.3",
@ -59,7 +59,7 @@
"terser-webpack-plugin": "^5.0.1",
"to-string-loader": "^1.2.0",
"ts-node": "^10.7.0",
"typescript": "^4.4.4",
"typescript": "^4.9.5",
"vue-loader": "^17.0.0",
"vue-tsc": "^1.0.24",
"webpack": "^5.31.2",

1839
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -256,7 +256,7 @@ const ThisComponent = defineComponent({
this.page++
const cards = await this.getCards()
this.cards.push(...cards)
this.hasMorePage = cards.length === 0 || this.cards.length < this.folder.count
this.hasMorePage = cards.length !== 0 || this.cards.length < this.folder.count
} catch (error) {
logError(error)
}

View File

@ -9,7 +9,7 @@
</div>
<div class="live-info">
<div class="live-title" :title="c.title">{{ c.title }}</div>
<div class="live-name" :title="c.name">{{ c.upName }}</div>
<div class="live-name" :title="c.upName">{{ c.upName }}</div>
</div>
</a>
</transition-group>

View File

@ -1,3 +1,16 @@
<script setup lang="ts">
import type { NavbarIframeConfig } from './iframe'
import type { CustomNavbarItem } from '../custom-navbar-item'
import { usePopup } from '../mixins'
const props = defineProps<{
item: CustomNavbarItem & NavbarIframeConfig
container: HTMLElement
}>()
const { el } = usePopup(props)
</script>
<template>
<iframe
ref="el"
@ -7,14 +20,3 @@
:height="item.height"
></iframe>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { popupProps, usePopup } from '../mixins'
export default defineComponent({
name: 'IframePopup',
props: popupProps,
setup: usePopup,
})
</script>

View File

@ -1,7 +1,7 @@
import { defineAsyncComponent } from 'vue'
import type { CustomNavbarItemInit } from '../custom-navbar-item'
interface NavbarIframeConfig {
export interface NavbarIframeConfig {
src: string
href: string
width: number

View File

@ -165,13 +165,6 @@ const ThisComponent = defineComponent({
async remove(aid: number, index: number) {
this.cards.splice(index, 1)
await this.toggleWatchlater(aid)
this.lastRemovedAid = aid
},
async undo() {
const aid = this.lastRemovedAid
if (aid !== 0) {
await this.toggleWatchlater(aid)
}
},
updateFilteredCards: lodash.debounce(updateFilteredCards, 100) as unknown as () => void,
},
@ -373,16 +366,5 @@ export default ThisComponent
}
}
}
.undo {
position: absolute;
bottom: 16px;
left: 50%;
opacity: 0;
transform: translateX(-50%) translateY(8px);
&.show {
opacity: 1;
transform: translateX(-50%) translateY(0px);
}
}
}
</style>

View File

@ -1436,7 +1436,6 @@ li.timeline:hover,
border-color: #99a2aa !important;
}
p.ri-title,
.read-push,
.link-more,
.dropdown-item,

View File

@ -1,4 +1,5 @@
#live_recommand_report, // b站以前写的recommand, 后来改对了
#live_recommend_report {
#live_recommend_report,
.video-container-v1 .pop-live-small-mode {
display: none !important;
}

View File

@ -19,7 +19,7 @@ export const component = defineComponentMetadata({
addComponentListener(
`${metadata.name}.offset`,
(value: number) => {
document.body.style.setProperty('--be-sidebar-offset', `${value}%`)
document.documentElement.style.setProperty('--be-sidebar-offset', `${value}%`)
},
true,
)

View File

@ -1,3 +1,3 @@
.be-settings > .sidebar {
top: calc(50% + var(--be-sidebar-offset)) !important;
top: calc(50% + var(--be-sidebar-offset, 0)) !important;
}

View File

@ -10,7 +10,7 @@
断开连接
</AsyncButton>
</template>
<template v-else class="status">
<template v-else>
<div class="status-dot disconnected" />
<div class="status-text">未连接</div>
<AsyncButton title="连接" :wait-on-click="connect">
@ -36,8 +36,6 @@ export default defineComponent({
data() {
return {
client: null as DevClient | null,
// sessions: [],
// devRecords: options.devRecords,
isConnected: false,
}
},
@ -46,12 +44,10 @@ export default defineComponent({
this.client = devClient
this.updateConnectionStatus()
devClient.addEventListener(DevClientEvents.ServerChange, this.updateConnectionStatus)
// devClient.addEventListener(DevClientEvents.SessionsUpdate, this.updateSessionsStatus)
},
beforeUnmount() {
const devClient = this.client as DevClient
devClient.removeEventListener(DevClientEvents.ServerChange, this.updateConnectionStatus)
// devClient.removeEventListener(DevClientEvents.SessionsUpdate, this.updateSessionsStatus)
},
methods: {
async connect() {
@ -63,9 +59,6 @@ export default defineComponent({
updateConnectionStatus() {
this.isConnected = this.client.isConnected
},
updateSessionsStatus() {
this.sessions = [...this.client.sessions]
},
},
})
</script>

View File

@ -30,6 +30,10 @@ export const imageResolution = async (dpi: number, element: HTMLElement) => {
if (excludeSelectors.some(it => element.matches(it))) {
return
}
// 带 , 的 srcset 不处理
if (value.includes(',')) {
return
}
const match = value.match(resizeRegex)
if (!match) {
return
@ -58,6 +62,10 @@ export const imageResolution = async (dpi: number, element: HTMLElement) => {
e => e.getAttribute('src'),
(e, v) => e.setAttribute('src', v),
)
replaceSource(
e => e.getAttribute('srcset'),
(e, v) => e.setAttribute('srcset', v),
)
replaceSource(
e => e.style.backgroundImage,
(e, v) => (e.style.backgroundImage = v),

View File

@ -19,7 +19,7 @@ const importSeries = async (sid, uid, csrf) => {
// 创建收藏夹获取新收藏夹id
let favId = 0
while (true) {
for (;;) {
const response = await fetch('https://api.bilibili.com/x/v3/fav/folder/add', {
method: 'POST',
credentials: 'include',
@ -43,7 +43,7 @@ const importSeries = async (sid, uid, csrf) => {
for (let i = 0; i < seriesVideos.length; i++) {
// 做个延迟,防止太快而遭服务器拒绝
await delay(500)
while (true) {
for (;;) {
const response = await fetch('https://api.bilibili.com/x/v3/fav/resource/deal', {
method: 'POST',
credentials: 'include',
@ -72,7 +72,7 @@ const importSeries = async (sid, uid, csrf) => {
}
const importCollection = async (sid, csrf) => {
while (true) {
for (;;) {
const response = await fetch('https://api.bilibili.com/x/v3/fav/season/fav', {
method: 'POST',
credentials: 'include',

View File

@ -1,3 +1,4 @@
.bili-dm,
.b-danmaku {
&.airborne {
text-decoration: underline;

View File

@ -52,7 +52,7 @@ export const component = defineComponentMetadata({
return
}
const target = e.target as HTMLElement
if (!target.classList.contains('b-danmaku')) {
if (!['b-danmaku', 'bili-dm'].some(token => target.classList.contains(token))) {
return
}
const time = getAirborneTime(target.textContent)
@ -60,11 +60,11 @@ export const component = defineComponentMetadata({
unsafeWindow.player.seek(time, false)
}
}
const addAirborneStyle = (danmaku: DanmakuRecord) => {
const detectAirborne = (danmaku: DanmakuRecord) => {
const canAirborne = !Number.isNaN(getAirborneTime(danmaku.text))
danmaku.element.classList.toggle('airborne', canAirborne)
}
forEachVideoDanmaku({ added: addAirborneStyle })
forEachVideoDanmaku({ added: detectAirborne })
videoChange(async () => {
const wrapper = (await playerAgent.query.video.wrap()) as HTMLElement
if (wrapper.classList.contains('airborne-enabled')) {

View File

@ -285,3 +285,34 @@ const decode = lodash.curry(async (type: string, blob: Blob) => {
})
export const decodeDanmakuSegment = decode('DmSegMobileReply')
export const decodeDanmakuView = decode('DmWebViewReply')
// 这里为了兼容 pakku, 只能用 fetch https://github.com/xmcp/pakku.js/issues/153
const fetchBlob = async (url: string) => {
const response = await fetch(url)
return response.blob()
}
export const getDanmakuView = async (aid: string | number, cid: string | number) => {
const viewBlob = await fetchBlob(
`https://api.bilibili.com/x/v2/dm/web/view?type=1&oid=${cid}&pid=${aid}`,
)
if (!viewBlob) {
throw new Error('获取弹幕信息失败')
}
const view = await decodeDanmakuView(viewBlob)
return view
}
export const getDanmakuSegment = async (
aid: string | number,
cid: string | number,
index: number,
) => {
const blob = await fetchBlob(
`https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${cid}&pid=${aid}&segment_index=${
index + 1
}`,
)
if (!blob) {
throw new Error(`弹幕片段${index + 1}下载失败`)
}
return decodeDanmakuSegment(blob)
}

View File

@ -43,19 +43,8 @@ export class JsonDanmaku {
}))
}
async fetchInfo() {
const { decodeDanmakuSegment, decodeDanmakuView } = await import('../converter/danmaku-segment')
// 这里为了兼容 pakku, 只能用 fetch https://github.com/xmcp/pakku.js/issues/153
const fetchBlob = async (url: string) => {
const response = await fetch(url)
return response.blob()
}
const viewBlob = await fetchBlob(
`https://api.bilibili.com/x/v2/dm/web/view?type=1&oid=${this.cid}&pid=${this.aid}`,
)
if (!viewBlob) {
throw new Error('获取弹幕信息失败')
}
const view = await decodeDanmakuView(viewBlob)
const { getDanmakuSegment, getDanmakuView } = await import('../converter/danmaku-segment')
const view = await getDanmakuView(this.aid, this.cid)
const { total } = view.dmSge
if (total === undefined) {
throw new Error(`获取弹幕分页数失败: ${JSON.stringify(lodash.omit(view, 'flag'))}`)
@ -63,18 +52,14 @@ export class JsonDanmaku {
console.log('segment count =', total)
const segments = await Promise.all(
new Array(total).fill(0).map(async (_, index) => {
const blob = await fetchBlob(
`https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${this.cid}&pid=${
this.aid
}&segment_index=${index + 1}`,
)
if (!blob) {
logError(new Error(`弹幕片段${index + 1}下载失败`))
return []
}
console.log(`received blob for segment ${index + 1}`, blob)
const result = await decodeDanmakuSegment(blob)
try {
const result = await getDanmakuSegment(this.aid, this.cid, index)
console.log(`received blob for segment ${index + 1}`, result)
return result.elems ?? []
} catch (error) {
logError(error)
throw error
}
}),
)
this.jsonDanmakus = segments.flat().sort(ascendingSort(it => it.progress ?? 0))

View File

@ -0,0 +1 @@
每次加载视频时自动展开弹幕列表, 如果弹幕装载量超过 `最大弹幕数量`, 则不展开以免对页面造成卡顿. (`最大弹幕数量` 是和视频的总弹幕量对比, 不一定和播放器实际装载的弹幕量相等)

View File

@ -1,47 +1,20 @@
import { defineComponentMetadata } from '@/components/define'
import type { ComponentEntry } from '@/components/types'
import { videoChange } from '@/core/observer'
import { select, sq } from '@/core/spin-query'
import { select } from '@/core/spin-query'
import { matchUrlPattern } from '@/core/utils'
import { useScopedConsole } from '@/core/utils/log'
import { mediaListUrls, videoAndBangumiUrls } from '@/core/utils/urls'
const parseCountMatch = (countMatch: RegExpMatchArray) => {
if (!countMatch) {
return null
}
const count = parseInt(countMatch[0])
if (Number.isNaN(count)) {
return null
}
return count
}
// 2.x: n
const getDanmakuCountV2 = async () => {
const countElement = await select('.bilibili-player-video-info-danmaku-number')
const countText = countElement?.textContent ?? ''
const countMatch = countText.match(/\d+/)
return parseCountMatch(countMatch)
}
// 3.x: 已装填 n 条弹幕
const getDanmakuCountV3 = async () => {
const countElement = await select('.bpx-player-video-info-dm')
await sq(
() => dq('.bpx-player-video-info-online b'),
it => it && it.textContent !== '-',
)
const countText = countElement?.textContent ?? ''
const countMatch = countText.match(/\d+/)
return parseCountMatch(countMatch)
}
const entry: ComponentEntry = async ({ settings: { options } }) => {
const console = useScopedConsole('展开弹幕列表')
videoChange(async () => {
const { getDanmakuView } = await import('../converter/danmaku-segment')
videoChange(async ({ aid, cid }) => {
if (mediaListUrls.some(url => matchUrlPattern(url)) && options.ignoreMediaList) {
return
}
const danmakuCount = await Promise.race([getDanmakuCountV2(), getDanmakuCountV3()])
const view = await getDanmakuView(aid, cid)
const danmakuCount = view.count
console.log(`当前弹幕量: ${danmakuCount}`)
if (danmakuCount !== null && danmakuCount > options.maxDanmakuCount) {
console.log(`超过了最大弹幕数量 ${options.maxDanmakuCount}, 跳过展开`)
@ -64,10 +37,6 @@ export const component = defineComponentMetadata({
displayName: '展开弹幕列表',
entry,
tags: [componentsTags.video],
description: {
'zh-CN':
'每次加载视频时自动展开弹幕列表, 如果弹幕装载量超过 `最大弹幕数量`, 则不展开以免对页面造成卡顿.',
},
options: {
ignoreMediaList: {
defaultValue: true,

View File

@ -0,0 +1 @@
将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.

View File

@ -0,0 +1,19 @@
import { defineComponentMetadata } from '@/components/define'
import { forEachVideoDanmaku } from '@/components/video/video-danmaku'
export const component = defineComponentMetadata({
name: 'unescapeDanmaku',
displayName: '弹幕转义',
tags: [componentsTags.video],
entry: () => {
const newLineRegex = /\\n/g
forEachVideoDanmaku({
added: danmaku => {
if (newLineRegex.test(danmaku.text)) {
const newText = danmaku.text.replace(newLineRegex, '\n')
danmaku.element.textContent = newText
}
},
})
},
})

View File

@ -448,6 +448,7 @@ export class ExtendSpeedComponent extends EntrySpeedComponent<Options> {
setTimeout(() => this.forceUpdateStyle(value))
}
// eslint-disable-next-line class-methods-use-this
protected readonly filterNativeSpeed =
() =>
({ subscribe, next }: PublishContext<number>) => {

View File

@ -6,7 +6,7 @@ import { playerUrls } from '@/core/utils/urls'
const entry = async () => {
let lastAid: string
const removeCover = () => document.body.style.removeProperty('--cover-url')
const removeCover = () => document.documentElement.style.removeProperty('--cover-url')
const videoPrototype = (await isBwpVideo()) ? BwpElement.prototype : HTMLVideoElement.prototype
// bpx player 改了 video.play(), hook 直接挂 HTMLVideoElement.prototype 上没效果 (#3698)
const bpxPlayer = dq('.bpx-player-video-wrap')
@ -41,7 +41,7 @@ const entry = async () => {
const { VideoInfo } = await import('@/components/video/video-info')
const info = new VideoInfo(aid)
await info.fetchInfo()
document.body.style.setProperty('--cover-url', `url('${info.coverUrl}')`)
document.documentElement.style.setProperty('--cover-url', `url('${info.coverUrl}')`)
}
videoChange(showCover)
}

View File

@ -1,5 +1,5 @@
{
"version": "2.6.0",
"version": "2.6.3",
"author": "Grant Howard, Coulomb-G",
"copyright": "[year], Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)",
"license": "MIT",

View File

@ -0,0 +1,62 @@
<template>
<div class="bisector-dialog-content-container">
<div class="bisector-dialog-content-text">
组件二等分进行中预计剩余
<b style="color: var(--theme-color)">{{ rouge }} </b>轮次请确认当前情况
</div>
<div class="bisector-dialog-button-group">
<VButton @click="$emit('good')">正常</VButton>
<VButton @click="$emit('bad')">不正常</VButton>
<VButton type="primary" @click="$emit('abort')">终止</VButton>
</div>
<div class="bisector-dialog-content-text weak">
*可以点击×号关闭对话框刷新页面后会再次询问
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VButton } from '@/ui'
export default defineComponent({
components: { VButton },
props: {
rouge: {
type: Number,
required: true,
},
},
emits: {
abort: null as () => void,
good: null as () => void,
bad: null as () => void,
},
})
</script>
<style lang="scss" scoped>
.bisector-dialog-content-container {
padding: 0 16px;
.bisector-dialog-content-text {
&.weak {
opacity: 0.6;
}
}
.bisector-dialog-content-text,
.bisector-dialog-button-group {
margin-bottom: 8px;
}
.bisector-dialog-button-group {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
.be-button:last-child {
grid-column: span 2;
}
}
}
</style>

View File

@ -0,0 +1,43 @@
<template>
<div>
组件二等分
<div
class="peek"
title="透视"
style="margin-left: auto"
@mouseover="peek = true"
@mouseout="peek = false"
>
<VIcon icon="eye" :size="18" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VIcon } from '@/ui'
export default defineComponent({
components: { VIcon },
data() {
return { peek: false }
},
watch: {
peek(value) {
this.$el.closest('.be-dialog').style.opacity = value ? '0.1' : '1'
},
},
})
</script>
<style lang="scss" scoped>
@import 'common';
div {
@include h-center(8px);
}
.peek {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<div>
<div>可能出问题的组件是{{ displayName }}{{ name }}</div>
<div>
<span class="link" @click="restore">恢复原状{{ countdown }} </span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import type { UserComponent } from './api'
export default defineComponent({
props: {
userComponent: {
type: Object as PropType<UserComponent>,
required: true,
},
},
emits: {
restore: null as () => void,
},
data() {
return {
countdown: 30,
interval: undefined as number | undefined,
}
},
computed: {
displayName() {
return this.userComponent.metadata?.displayName
},
name() {
return this.userComponent.metadata?.name
},
},
watch: {
countdown(value) {
if (value === 0) {
this.restore()
}
},
},
created() {
this.interval = setInterval(() => {
if (this.countdown > 0) {
this.countdown--
} else {
clearInterval(this.interval)
}
}, 1e3)
},
unmounted() {
clearInterval(this.interval)
},
methods: {
restore() {
clearInterval(this.interval)
this.$emit('restore')
},
},
})
</script>

View File

@ -0,0 +1,196 @@
import type { DialogInstance } from '@/core/dialog'
import { showDialog } from '@/core/dialog'
import type { Settings } from '@/core/settings/types'
import { Toast } from '@/core/toast'
import { getRandomId, delay, createVueApp } from '@/core/utils'
import type { useScopedConsole } from '@/core/utils/log'
import type { RecordValue } from '../types'
import type { BisectNext, BisectReturn } from './bisect'
import { bisect } from './bisect'
import type { BisectorOptions } from './options'
import ResultToastContent from './ResultToastContent.vue'
export type UserComponent = RecordValue<Settings['userComponents']>
let bisectorOptions: BisectorOptions
let scopedConsole: ReturnType<typeof useScopedConsole>
let bisectorGenerator: Generator<BisectNext<UserComponent>, BisectReturn<UserComponent>>
let groupedComponents: Awaited<ReturnType<typeof classifyComponents>>
let dialog: DialogInstance
export const setOptions = (options: BisectorOptions) => {
if (!bisectorOptions) {
bisectorOptions = options
} else {
Object.assign(bisectorOptions, options)
}
}
export const setConsole = (console: ReturnType<typeof useScopedConsole>) => {
scopedConsole = console
}
const classifyComponents = async () => {
const { settings } = await import('@/core/settings')
const { userComponents } = settings
const configurableUserComponents = lodash.pickBy(
userComponents,
v => v.metadata.configurable || true,
)
const { targetComponents, keepDisabledComponents, keepEnabledComponents } = lodash.transform(
configurableUserComponents,
(result, v, k) => {
if (bisectorOptions.keepEnabledComponents?.includes(k)) {
result.keepEnabledComponents.push(v)
return
}
if (bisectorOptions.keepDisabledComponents?.includes(k)) {
result.keepDisabledComponents.push(v)
return
}
result.targetComponents.push(v)
},
{
targetComponents: [] as UserComponent[],
keepDisabledComponents: [] as UserComponent[],
keepEnabledComponents: [] as UserComponent[],
},
)
return {
userComponents,
configurableUserComponents,
targetComponents,
keepDisabledComponents,
keepEnabledComponents,
}
}
const setComponentsEnabled = (components: UserComponent[], enabled: boolean) =>
components.forEach(component => (component.settings.enabled = enabled))
const getComponentNames = (components: UserComponent[]) =>
components
.map(component => `${component.metadata.displayName}${component.metadata.name}`)
.join(', ') || '无'
export const isRecover = () => !lodash.isEmpty(bisectorOptions.bisectInitialState)
export const stop = async () => {
scopedConsole?.log('stop - 准备停止组件二等分')
dialog?.close()
const { configurableUserComponents } = await classifyComponents()
const unmatchedComponents: UserComponent[] = []
for (const [componentName, componentSettings] of Object.entries(configurableUserComponents)) {
const originalStatus = bisectorOptions.originalComponentEnableState?.[componentName]
if (originalStatus == null) {
unmatchedComponents.push(componentSettings)
continue
}
componentSettings.settings.enabled = originalStatus
}
if (unmatchedComponents.length) {
const msg = `部分组件未能还原状态:${getComponentNames(unmatchedComponents)}`
scopedConsole?.warn(`stop - ${msg}`)
Toast.error(msg, '组件二等分')
await delay(3e3)
}
scopedConsole?.log('stop - 清理状态')
bisectorGenerator = null
bisectorOptions.bisectInitialState = {}
bisectorOptions.originalComponentEnableState = {}
scopedConsole?.log('stop - 重载页面')
location.reload()
}
export const next = async (seeingBad?: boolean, autoReload?: boolean) => {
dialog?.close()
scopedConsole?.log(
`next - 当前工作状态:${
// eslint-disable-next-line no-nested-ternary
seeingBad == null ? '未知' : seeingBad ? '异常' : '正常'
}`,
)
const { done, value } = bisectorGenerator.next(seeingBad)
if (done) {
const { low, high } = value
bisectorOptions.bisectInitialState = { low, high }
const elementId = `bisector-result-toast-content-${getRandomId()}`
Toast.info(/* html */ `<div id="${elementId}"></div>`, '组件二等分结果')
await delay()
const vueApp = createVueApp(ResultToastContent, {
userComponent: value.target,
onRestore: stop,
})
vueApp.mount(`#${elementId}`)
} else {
const { slice, low, high } = value as BisectNext<UserComponent>
const needEnabled = slice
const needDisabled = lodash.difference(groupedComponents.targetComponents, slice)
bisectorOptions.bisectInitialState = { low, high }
scopedConsole?.log(`next - 关闭组件:${getComponentNames(needDisabled)}`)
scopedConsole?.log(`next - 开启组件:${getComponentNames(needEnabled)}`)
setComponentsEnabled(needDisabled, false)
setComponentsEnabled(needEnabled, true)
if (autoReload) {
scopedConsole?.log('next - 重载页面')
location.reload()
}
}
return { done, value }
}
export const recover = async () => {
scopedConsole?.log('recover - 准备恢复组件二等分')
groupedComponents = await classifyComponents()
const { targetComponents, keepDisabledComponents, keepEnabledComponents } = groupedComponents
setComponentsEnabled(keepEnabledComponents, true)
setComponentsEnabled(keepDisabledComponents, false)
scopedConsole?.log(`recover - 保持关闭组件:${getComponentNames(keepDisabledComponents)}`)
scopedConsole?.log(`recover - 保持开启组件:${getComponentNames(keepEnabledComponents)}`)
scopedConsole?.log(`recover - 全部目标组件:${getComponentNames(targetComponents)}`)
bisectorGenerator = bisect(targetComponents, bisectorOptions.bisectInitialState)
const { done, value } = await next()
if (!done) {
const { rouge } = value as BisectNext<UserComponent>
dialog = showDialog({
title: () => import('./DialogTitle.vue'),
content: () => import('./DialogContent.vue'),
contentProps: {
rouge,
onGood: () => next(false, true),
onBad: () => next(true, true),
onAbort: () => stop(),
},
})
}
}
export const start = async () => {
if (isRecover()) {
await recover()
return
}
scopedConsole?.log('start - 准备开始组件二等分')
groupedComponents = await classifyComponents()
const {
configurableUserComponents,
targetComponents,
keepDisabledComponents,
keepEnabledComponents,
} = groupedComponents
scopedConsole?.log('start - 保存组件初始启用状态')
bisectorOptions.originalComponentEnableState = lodash.mapValues(
configurableUserComponents,
v => v.settings.enabled,
)
setComponentsEnabled(targetComponents, true)
setComponentsEnabled(keepEnabledComponents, true)
setComponentsEnabled(keepDisabledComponents, false)
scopedConsole?.log(`start - 保持关闭组件:${getComponentNames(keepDisabledComponents)}`)
scopedConsole?.log(`start - 保持开启组件:${getComponentNames(keepEnabledComponents)}`)
scopedConsole?.log(`start - 启用全部目标组件:${getComponentNames(targetComponents)}`)
bisectorGenerator = bisect(targetComponents, bisectorOptions.bisectInitialState)
await next(undefined, true)
}

View File

@ -0,0 +1,92 @@
/* eslint-disable no-bitwise */
export interface BisectNext<O> {
low: number
high: number
mid: number
slice: O[]
rouge: number
}
export interface BisectReturn<O> extends BisectNext<O> {
target: O
}
export interface InitialState {
low?: number
high?: number
}
export function* bisectLeft<O>(
data: readonly O[],
initialState?: InitialState,
): Generator<BisectNext<O>, BisectReturn<O>> {
let low = initialState?.low ?? 0
let high = initialState?.high ?? data.length
let mid: number
while (low + 1 < high) {
mid = (low + high) >>> 1
const seeingBad = yield {
low,
high,
mid,
slice: data.slice(low, mid),
rouge: Math.trunc(Math.log2(high - low)),
}
if (seeingBad) {
high = mid
} else {
low = mid
}
}
return {
low,
high,
mid,
slice: data.slice(low, mid),
rouge: Math.trunc(Math.log2(high - low)),
target: data[low],
}
}
export function* bisectRight<O>(
data: readonly O[],
initialState?: InitialState,
): Generator<BisectNext<O>, BisectReturn<O>> {
let low = initialState?.low ?? 0
let high = initialState?.high ?? data.length
let mid = (low + high) >>> 1
while (low + 1 < high) {
mid = (low + high) >>> 1
const seeingBad = yield {
low,
high,
mid,
slice: data.slice(mid, high),
rouge: Math.trunc(Math.log2(high - low)),
}
if (seeingBad) {
low = mid
} else {
high = mid
}
}
return {
low,
high,
mid,
slice: data.slice(low, mid),
rouge: Math.trunc(Math.log2(high - low)),
target: data[low],
}
}
export const bisect = bisectLeft

View File

@ -0,0 +1,45 @@
import { defineComponentMetadata } from '@/components/define'
import { bisectorOptionsMetadata } from './options'
import { LifeCycleEventTypes } from '@/core/life-cycle'
import { componentsTags } from '@/components/types'
import * as bisector from './api'
import { useScopedConsole } from '@/core/utils/log'
import type { LaunchBarActionProvider } from '../launch-bar/launch-bar-action'
export const component = defineComponentMetadata({
name: 'bisector',
displayName: '组件二等分',
tags: [componentsTags.general, componentsTags.utils],
hidden: true,
configurable: false,
entry: async ({ settings: { options } }) => {
bisector.setOptions(options)
bisector.setConsole(useScopedConsole('组件二等分'))
unsafeWindow.addEventListener(LifeCycleEventTypes.ComponentsLoaded, () => {
if (bisector.isRecover()) {
bisector.recover()
}
})
},
options: bisectorOptionsMetadata,
plugin: {
displayName: '组件二等分 - 功能扩展',
setup: ({ addData }) => {
addData('launchBar.actions', (providers: LaunchBarActionProvider[]) => {
providers.push({
name: 'bisector-start',
getActions: async () => [
{
name: '开始 / 继续组件二等分',
description: 'Start/Continue component bisection',
icon: 'mdi-view-split-horizontal',
action: async () => {
await bisector.start()
},
},
],
})
})
},
},
})

View File

@ -0,0 +1,31 @@
import { defineOptionsMetadata, type OptionsOfMetadata } from '@/components/define'
import { getComponentSettings } from '@/core/settings'
import type { InitialState } from './bisect'
export const bisectorOptionsMetadata = defineOptionsMetadata({
// 原始的组件启用状态
originalComponentEnableState: {
defaultValue: {} as Record<string, boolean>,
hidden: true,
},
// 保持禁用状态的组件内部名称数组
keepDisabledComponents: {
defaultValue: [] as string[],
hidden: true,
},
// 保持启用状态的组件内部名称数组
keepEnabledComponents: {
defaultValue: [] as string[],
hidden: true,
},
// bisect 生成器的初始状态
bisectInitialState: {
defaultValue: {} as Partial<InitialState>,
hidden: true,
},
})
export const getBisectorOptions = () =>
getComponentSettings<OptionsOfMetadata<typeof bisectorOptionsMetadata>>('bisector').options
export type BisectorOptions = ReturnType<typeof getBisectorOptions>

View File

@ -2,6 +2,7 @@ import { component as AutoUpdate } from './auto-update'
import { component as I18n } from './i18n'
import { component as LaunchBar } from './launch-bar'
import { component as NotifyNewVersion } from './notify-new-version'
import { component as Bisector } from './bisector'
import { component as SettingsPanel } from './settings-panel'
import type { ComponentMetadata } from './types'
@ -11,6 +12,7 @@ export const getBuiltInComponents = (): ComponentMetadata[] => [
I18n,
AutoUpdate,
NotifyNewVersion,
Bisector,
]
export const isBuiltInComponent = (name: string) =>

View File

@ -144,19 +144,27 @@ export const loadComponent = async (component: ComponentMetadata) => {
/** 加载所有用户组件的定义 (不运行) */
export const loadAllUserComponents = async () => {
const { settings } = await import('@/core/settings')
const { loadFeaturesFromCodes, FeatureKind } = await import(
'@/core/external-input/load-features-from-codes'
)
const { loadFeatureCode } = await import('@/core/external-input/load-feature-code')
const loadUserComponent = (component: ComponentMetadata) => {
components.push(component)
componentsMap[component.name] = component
}
const userComponents = await loadFeaturesFromCodes(
FeatureKind.Component,
Object.keys(settings.userComponents),
Object.values(settings.userComponents).map(it => it.code),
)
userComponents.forEach(loadUserComponent)
for (const [name, setting] of Object.entries(settings.userComponents)) {
const { code } = setting
let metadata: ComponentMetadata
try {
metadata = loadFeatureCode(code) as ComponentMetadata
} catch (e) {
console.error('从代码加载用户组件失败。代码可能有语法错误或代码执行时有抛出值。', {
componentName: name,
error: e,
})
continue
}
loadUserComponent(metadata)
}
}
/** 载入所有组件 */
export const loadAllComponents = async () => {

View File

@ -89,6 +89,7 @@ const parseCard = async (element: HTMLElement): Promise<FeedsCard> => {
combineText(getText(modules.module_dynamic, cardType), getText(repostDynamicModule, cardType))
}
card.text = await card.getText()
card.element.setAttribute('data-did', card.id)
// 等待第一次 Vue 渲染完成
await selectAll(() => element.querySelectorAll('.bili-dyn-item *'), { queryInterval: 50 })
return card

View File

@ -13,11 +13,15 @@ export class Translator {
static map: Map<string, any>
static regex: [RegExp, string][]
// eslint-disable-next-line class-methods-use-this
protected accepts = (node: Node) => node.nodeType === Node.ELEMENT_NODE
// eslint-disable-next-line class-methods-use-this
protected getValue = (node: Node) => node.nodeValue
// eslint-disable-next-line class-methods-use-this
protected setValue = (node: Node, value: string) => {
node.nodeValue = value
}
// eslint-disable-next-line class-methods-use-this
protected getElement = (node: Node) => node as Element
translate(node: Node) {
let value = this.getValue(node)
@ -100,17 +104,23 @@ export class Translator {
}
}
export class TextNodeTranslator extends Translator {
// eslint-disable-next-line class-methods-use-this
accepts = (node: Node) => node.nodeType === Node.TEXT_NODE
// eslint-disable-next-line class-methods-use-this
getElement = (node: Node) => node.parentElement
}
export class TitleTranslator extends Translator {
// eslint-disable-next-line class-methods-use-this
getValue = (node: Node) => (node as Element).getAttribute('title')
// eslint-disable-next-line class-methods-use-this
setValue = (node: Node, value: string) => {
;(node as Element).setAttribute('title', value)
}
}
export class PlaceholderTranslator extends Translator {
// eslint-disable-next-line class-methods-use-this
getValue = (node: Node) => (node as Element).getAttribute('placeholder')
// eslint-disable-next-line class-methods-use-this
setValue = (node: Node, value: string) => {
;(node as Element).setAttribute('placeholder', value)
}

View File

@ -62,16 +62,16 @@ export default defineComponent({
'next-item': null as (e: KeyboardEvent) => true,
},
methods: {
performAction(event: Event) {
performAction(event: KeyboardEvent | MouseEvent) {
this.action.action()
this.$emit('action', event as KeyboardEvent | MouseEvent)
this.$emit('action', event)
},
performDelete(event: Event) {
performDelete(event: KeyboardEvent | MouseEvent) {
if (!this.action.deleteAction) {
return
}
this.action.deleteAction()
this.$emit('delete-item', event as KeyboardEvent | MouseEvent)
this.$emit('delete-item', event)
},
},
})

View File

@ -78,18 +78,25 @@ import { historyProvider } from './history-provider'
import type { LaunchBarAction, LaunchBarActionProvider } from './launch-bar-action'
import { LaunchBarActionProviders } from './launch-bar-action'
import { search, searchProvider } from './search-provider'
import { ascendingSort } from '@/core/utils/sort'
const [actionProviders] = registerAndGetData(LaunchBarActionProviders, [
searchProvider,
historyProvider,
]) as [LaunchBarActionProvider[]]
interface LaunchBarActionData extends LaunchBarAction {
key: string
provider: LaunchBarActionProvider
}
const sortActions = (actions: LaunchBarActionData[]): LaunchBarActionData[] => {
return [...actions].sort(ascendingSort(it => it.order ?? Infinity))
}
const generateKeys = (
provider: LaunchBarActionProvider,
actions: LaunchBarAction[],
): ({
key: string
provider: LaunchBarActionProvider
} & LaunchBarAction)[] =>
): LaunchBarActionData[] =>
actions.map(a => {
const key = `${provider.name}.${a.name}`
return {
@ -114,13 +121,15 @@ async function getOnlineActions(this: InstanceType<typeof ThisComponent>) {
})
const fuseResult = fuse.search(this.keyword)
console.log(fuseResult)
this.actions = fuseResult.map(it => it.item).slice(0, 12)
this.actions = sortActions(fuseResult.map(it => it.item).slice(0, 12))
this.noActions = this.actions.length === 0
}
async function getActions(this: InstanceType<typeof ThisComponent>) {
this.noActions = false
if (this.isHistory) {
this.actions = generateKeys(historyProvider, await historyProvider.getActions(this.keyword))
this.actions = sortActions(
generateKeys(historyProvider, await historyProvider.getActions(this.keyword)),
)
return
}
this.actions = []

View File

@ -1,22 +1,22 @@
// import { formatDate } from '@/core/utils/formatters'
import { crossOriginLocalStorage } from '@/core/local-storage'
import type { LaunchBarAction, LaunchBarActionProvider } from './launch-bar-action'
export interface HistoryItem {
value: string
isHistory: number
timestamp: number
}
const SearchHistoryKey = 'search_history'
const SearchHistoryMaxItems = 12
export const getHistoryItems = (key = SearchHistoryKey) => {
const historyText = localStorage.getItem(key)
const SearchHistoryKey = 'search_history:search_history'
const SearchHistoryMaxItems = 20
export const getHistoryItems = async (key = SearchHistoryKey) => {
const historyText = await crossOriginLocalStorage.getItem(key)
const historyItems: HistoryItem[] = historyText ? JSON.parse(historyText) : []
return historyItems
}
export const clearHistoryItems = (key = SearchHistoryKey) => localStorage.setItem(key, '[]')
export const addHistoryItem = (keyword: string, key = SearchHistoryKey) => {
export const clearHistoryItems = (key = SearchHistoryKey) =>
crossOriginLocalStorage.setItem(key, '[]')
export const addHistoryItem = async (keyword: string, key = SearchHistoryKey) => {
console.log('add', keyword)
localStorage.setItem(
await crossOriginLocalStorage.setItem(
key,
JSON.stringify(
lodash
@ -25,10 +25,9 @@ export const addHistoryItem = (keyword: string, key = SearchHistoryKey) => {
[
{
value: keyword,
isHistory: 1,
timestamp: Number(new Date()),
},
...getHistoryItems(),
...(await getHistoryItems()),
],
h => h.value,
),
@ -39,13 +38,13 @@ export const addHistoryItem = (keyword: string, key = SearchHistoryKey) => {
),
)
}
export const deleteHistoryItem = (keyword: string, key = SearchHistoryKey) => {
const items = getHistoryItems()
export const deleteHistoryItem = async (keyword: string, key = SearchHistoryKey) => {
const items = await getHistoryItems()
const index = items.findIndex(it => it.value === keyword)
console.log('delete', keyword, index)
if (index !== -1) {
items.splice(index, 1)
localStorage.setItem(key, JSON.stringify(items))
await crossOriginLocalStorage.setItem(key, JSON.stringify(items))
}
}
export const historyProvider: LaunchBarActionProvider = {
@ -57,11 +56,12 @@ export const historyProvider: LaunchBarActionProvider = {
icon: 'mdi-trash-can-outline',
description: 'Clear History',
explicitSelect: true,
action: () => {
clearHistoryItems()
action: async () => {
await clearHistoryItems()
},
}
const items = getHistoryItems().map(
const historyItems = await getHistoryItems()
const items = historyItems.map(
it =>
({
name: it.value,
@ -71,8 +71,8 @@ export const historyProvider: LaunchBarActionProvider = {
action: () => {
search(it.value)
},
deleteAction: () => {
deleteHistoryItem(it.value)
deleteAction: async () => {
await deleteHistoryItem(it.value)
},
} as LaunchBarAction),
)

View File

@ -21,6 +21,8 @@ export interface LaunchBarAction {
deleteAction?: Executable
/** 显式选中模式: 开启后可以禁止在列表第一项时直接由 Enter 触发 */
explicitSelect?: boolean
/** 手动指定在搜索结果中的顺序, 数字越小越排前面 */
order?: number
}
/** LaunchBar 动作提供者的插件key, 可注入其他提供者 */
export const LaunchBarActionProviders = 'launchBar.actions'

View File

@ -29,7 +29,7 @@ export const searchProvider: LaunchBarActionProvider = {
content: defineAsyncComponent(
async () => () =>
h('div', {
innerHTML: /* html */ `<em class="suggest-highlight">${input}</em>`,
innerHTML: /* html */ `<span>${input}</span>`,
}),
),
action: () => search(input),

View File

@ -218,3 +218,6 @@ export interface ComponentMetadata<O extends UnknownOptions = UnknownOptions>
/** 用户组件的非函数基本信息, 用于直接保存为 JSON */
export type UserComponentMetadata = Omit<ComponentMetadata, keyof FunctionalMetadata>
/** 推断 Record 的 Value 类型 */
export type RecordValue<R> = R extends Record<any, infer V> ? V : never

View File

@ -3,6 +3,8 @@ import { componentToSettings } from '@/core/settings'
import { isBuiltInComponent } from './built-in-components'
import type { ComponentMetadata } from './component'
import { componentsMap } from './component'
import * as bisector from './bisector/api'
import type { BisectorOptions } from './bisector/options'
/**
*
@ -10,10 +12,12 @@ import { componentsMap } from './component'
*/
export const installComponent = async (code: string) => {
const { components } = await import('./component')
const { parseExternalInput } = await import('../core/external-input')
const component = await parseExternalInput<ComponentMetadata>(code)
if (component === null) {
throw new Error('无效的组件代码')
const { loadFeatureCode } = await import('@/core/external-input')
let component: ComponentMetadata
try {
component = loadFeatureCode(code) as ComponentMetadata
} catch (e) {
throw new Error('无效的组件代码', { cause: e })
}
const { settings } = await import('@/core/settings')
if (isBuiltInComponent(component.name)) {
@ -132,3 +136,16 @@ export const toggleComponent = async (nameOrDisplayName: string) => {
const { displayName } = userComponent.metadata
return `${enabled ? '开启' : '关闭'}组件'${displayName}', 可能需要刷新后才能生效`
}
/**
*
*
* @param options
* @returns
*/
export const bisectComponent = async (options?: BisectorOptions) => {
if (options) {
bisector.setOptions(options)
}
return bisector
}

View File

@ -13,7 +13,8 @@ const parseHexColor = (hexColor: string) => {
}
export const convertHexColorForDialogue = (hexColor: string) => {
const { red, green, blue } = parseHexColor(hexColor)
return `\\c&H${blue}${green}${red}&`.toUpperCase()
const hex = `${blue}${green}${red}`.toUpperCase()
return `\\c&H${hex}&`
}
export const convertHexColorForStyle = (hexColor: string, opacity = 1) => {
const { red, green, blue } = parseHexColor(hexColor)

View File

@ -3,7 +3,6 @@ import { hasVideo } from '@/core/spin-query'
import { playerAgent } from './player-agent'
let danmakuContainerObserver: MutationObserver
export interface DanmakuRecord {
element: HTMLElement
reuse: boolean
@ -13,17 +12,14 @@ export interface DanmakuRecordCallback {
added?: (danmaku: DanmakuRecord) => void
removed?: (danmaku: DanmakuRecord) => void
}
const recordedDanmakus: DanmakuRecord[] = []
const parseDanmakuRecord = (element: HTMLElement) => ({
element,
reuse: false,
text: element.textContent || '',
})
const startRecording = (container: HTMLElement, callback: DanmakuRecordCallback) => {
if (danmakuContainerObserver) {
danmakuContainerObserver.disconnect()
}
;[danmakuContainerObserver] = childListSubtree(container, records => {
const recordedDanmakus: DanmakuRecord[] = []
childListSubtree(container, records => {
records.forEach(record => {
record.addedNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {

View File

@ -2,12 +2,14 @@ import { componentApis } from '@/components/api'
import * as ajax from '@/core/ajax'
import * as cdnTypes from '@/core/cdn-types'
import * as download from '@/core/download'
import * as dialog from '@/core/dialog'
import * as externalInput from '@/core/external-input'
import * as filePicker from '@/core/file-picker'
import * as horizontalScroll from '@/core/horizontal-scroll'
import * as installFeature from '@/core/install-feature'
import * as lifeCycle from '@/core/life-cycle'
import * as loadingMode from '@/core/loading-mode'
import * as localStorage from '@/core/local-storage'
import * as meta from '@/core/meta'
import * as observer from '@/core/observer'
import * as reorder from '@/core/reorder'
@ -39,12 +41,14 @@ export const coreApis = {
ajax,
cdnTypes,
download,
dialog,
externalInput,
filePicker,
installFeature,
horizontalScroll,
lifeCycle,
loadingMode,
localStorage,
meta,
observer,
reorder,
@ -78,12 +82,14 @@ export const externalApis = {
ajax,
...cdnTypes,
...download,
...dialog,
...externalInput,
...filePicker,
...installFeature,
...horizontalScroll,
lifeCycle,
...loadingMode,
...localStorage,
...meta,
observer,
...reorder,

View File

@ -12,10 +12,10 @@ export interface DialogInputs {
}
export interface DialogInstance extends Required<DialogInputs> {
open: boolean
close: Promise<void>
close(): Promise<void>
closeListeners: (() => void)[]
}
export const showDialog = (inputs: DialogInputs) => {
export const showDialog = (inputs: DialogInputs): DialogInstance => {
const { icon, title, zIndex, content, contentProps } = inputs
let dialogEl: HTMLDivElement | undefined
let dialogApp: App<HTMLDivElement> | undefined
@ -46,5 +46,5 @@ export const showDialog = (inputs: DialogInputs) => {
setTimeout(() => {
vm.open = true
})
return vm
return vm as DialogInstance
}

View File

@ -1,5 +1,7 @@
import { pickFile } from '@/core/file-picker'
export * from './load-feature-code'
/** 外部输入包装类型, 详见`parseExternalInput`的文档 */
export type ExternalInput<T> = undefined | string | T
// const arrayReturn = <InputType extends any[], OutputType>(

View File

@ -1,27 +0,0 @@
import type { LoadFeatureCodeResult } from '@/core/external-input/load-feature-code'
import { loadFeatureCode } from '@/core/external-input/load-feature-code'
type LdRes<X> = LoadFeatureCodeResult<X>
type SettledRes<T> = PromiseSettledResult<T>
type FilledRes<T> = PromiseFulfilledResult<T>
const unwrapSettledResult = <T>(r: SettledRes<T>): T => (r as FilledRes<T>).value
const mapSettledArray = <T>(arr: SettledRes<T>[]): T[] => arr.map(unwrapSettledResult)
const mapSettleResult = <T>(p: Promise<SettledRes<T>[]>): Promise<T[]> => p.then(mapSettledArray)
/**
* feature
*
* @param codes
* @returns `Promise`
*/
export const loadFeatureCodeAllSettled = <X>(
codes: string[],
): Promise<LoadFeatureCodeResult<X>[]> =>
lodash(codes)
.map<Promise<LdRes<X>>>(loadFeatureCode)
.thru<Promise<SettledRes<LdRes<X>>[]>>(arr => Promise.allSettled(arr))
.thru(mapSettleResult)
.value()

View File

@ -1,156 +0,0 @@
import type { FeatureBase } from '@/components/types'
import type {
LoadFeatureCodeResult,
LoadFeatureCodeResultError,
LoadFeatureCodeResultOk,
} from '@/core/external-input/load-feature-code'
import { loadFeatureCode } from '@/core/external-input/load-feature-code'
interface ResultInstance {
readonly isOk: <X extends FeatureBase>(
this: LoadFeatureCodeAllResult<X>,
) => this is LoadFeatureCodeAllResultOk<X>
readonly isError: (
this: LoadFeatureCodeAllResult<FeatureBase>,
) => this is LoadFeatureCodeAllResultError
readonly isNoExport: (
this: LoadFeatureCodeAllResult<FeatureBase>,
) => this is LoadFeatureCodeAllResultNoExport
readonly isCodeThrew: (
this: LoadFeatureCodeAllResult<FeatureBase>,
) => this is LoadFeatureCodeAllResultCodeThrew
}
/**
* features
*
* @namespace
* @property features
*/
interface LoadFeatureCodeAllResultOk<X extends FeatureBase> extends ResultInstance {
readonly tag: 'Ok'
readonly features: X[]
}
/** 代码没有导出任何值 */
interface LoadFeatureCodeAllResultNoExport extends ResultInstance {
readonly tag: 'NoExport'
}
/**
*
*
* @namespace
* @property thrown
*/
interface LoadFeatureCodeAllResultCodeThrew extends ResultInstance {
readonly tag: 'CodeThrew'
readonly thrown: unknown
}
type LoadFeatureCodeAllResultError =
| LoadFeatureCodeAllResultNoExport
| LoadFeatureCodeAllResultCodeThrew
type LoadFeatureCodeAllResult<X extends FeatureBase> =
| LoadFeatureCodeAllResultOk<X>
| LoadFeatureCodeAllResultError
const resultProto: ResultInstance = {
isOk() {
return this.tag === 'Ok'
},
isError() {
return this.tag !== 'Ok'
},
isNoExport() {
return this.tag === 'NoExport'
},
isCodeThrew() {
return this.tag === 'CodeThrew'
},
}
const okResult = <X extends FeatureBase>(features: X[]): LoadFeatureCodeAllResultOk<X> =>
lodash.create(resultProto, {
tag: 'Ok' as const,
features,
})
const noExportResult = lodash.create(resultProto, {
tag: 'NoExport' as const,
})
const codeThrewResult = (thrown: unknown): LoadFeatureCodeAllResultCodeThrew =>
lodash.create(resultProto, {
tag: 'CodeThrew' as const,
thrown,
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Task<Ok, Err = never> = Promise<Ok>
type LdRes<X> = LoadFeatureCodeResult<X>
type LdOk<X> = LoadFeatureCodeResultOk<X>
type LdErr = LoadFeatureCodeResultError
type LdAllRes<X> = LoadFeatureCodeAllResult<X>
type LdAllOk<X> = LoadFeatureCodeAllResultOk<X>
type LdAllErr = LoadFeatureCodeAllResultError
type LoadCodesTask<X> = Task<LdOk<X>[], [number, LdErr]>
// covert `Task<LdRes<X>>` to `Task<LdOk<X>, LdErr>`
const rejectErrorResult = <X>(t: Task<LdRes<X>>): Task<LdOk<X>, LdErr> =>
t.then(r => (r.isOk() ? r : Promise.reject(r)))
// load feature code, and return `Task<LdOk<X>, LdErr>`
const loadCode = <X>(code: string): Task<LdOk<X>, LdErr> => rejectErrorResult(loadFeatureCode(code))
// covert `Task`'s `Err` type from `T` to `[number, T]`
const addIndexToRejected = <N extends number, O, E>(
t: Task<O, E>,
i: N,
// eslint-disable-next-line prefer-promise-reject-errors
): Task<O, [N, E]> => t.catch(e => Promise.reject([i, e]))
// create `LdAllOk` from an array of `LdOk`
const createOkResult = <X>(arr: LdOk<X>[]): LdAllOk<X> => okResult(arr.map(r => r.feature))
// create `LdAllErr` from `[number, LdErr]`
const createErrResult = (t: [number, LdErr]): LdAllErr =>
t[1].isCodeThrew() ? codeThrewResult(t[1].thrown) : noExportResult
// load all feature codes, and return `LoadCodesTask`
const loadCodes = <X>(codes: string[]): LoadCodesTask<X> =>
lodash(codes)
.map<Task<LdOk<X>, LdErr>>(loadCode)
.map(addIndexToRejected)
.thru<LoadCodesTask<X>>(arr => Promise.all(arr))
.value()
// create a `LdAllRes` wrapped by `Task`
const createTaskResult = <X>(t: LoadCodesTask<X>): Task<LdAllRes<X>> =>
t.then(createOkResult).catch(createErrResult)
/**
* feature
*
*
*
* @param codes
* @returns `Promise` `LoadFeatureCodeAllResult`
*/
const loadFeatureCodeAll = <X>(codes: string[]): Promise<LoadFeatureCodeAllResult<X>> =>
lodash(codes).thru<LoadCodesTask<X>>(loadCodes).thru(createTaskResult).value()
export {
loadFeatureCodeAll,
type LoadFeatureCodeAllResult,
type LoadFeatureCodeAllResultCodeThrew,
type LoadFeatureCodeAllResultError,
type LoadFeatureCodeAllResultNoExport,
type LoadFeatureCodeAllResultOk,
}

View File

@ -1,116 +1,87 @@
import type { FeatureBase } from '@/components/types'
export class LoadFeatureCodeError extends Error {}
interface ResultInstance {
readonly isOk: <X extends FeatureBase>(
this: LoadFeatureCodeResult<X>,
) => this is LoadFeatureCodeResultOk<X>
readonly isError: (this: LoadFeatureCodeResult<FeatureBase>) => this is LoadFeatureCodeResultError
readonly isNoExport: (
this: LoadFeatureCodeResult<FeatureBase>,
) => this is LoadFeatureCodeResultNoExport
readonly isCodeThrew: (
this: LoadFeatureCodeResult<FeatureBase>,
) => this is LoadFeatureCodeResultCodeThrew
/**
* feature
*/
type CodeSandbox = {
/**
*
*
* @remarks
* {@link loadFeatureCode}
*
* @returns `[导出值, 返回值]`
* @throws {@link LoadFeatureCodeError}
*
*/
run(code: string): [unknown, unknown]
}
/**
* feature
* feature
*
* @namespace
* @property feature
* @returns `CodeSandbox`
*/
interface LoadFeatureCodeResultOk<X extends FeatureBase> extends ResultInstance {
readonly tag: 'Ok'
readonly feature: X
}
/** 代码没有导出任何值 */
interface LoadFeatureCodeResultNoExport extends ResultInstance {
readonly tag: 'NoExport'
}
/**
*
*
* @namespace
* @property thrown
*/
interface LoadFeatureCodeResultCodeThrew extends ResultInstance {
readonly tag: 'CodeThrew'
readonly thrown: unknown
}
type LoadFeatureCodeResultError = LoadFeatureCodeResultNoExport | LoadFeatureCodeResultCodeThrew
type LoadFeatureCodeResult<X extends FeatureBase> =
| LoadFeatureCodeResultOk<X>
| LoadFeatureCodeResultError
const resultProto: ResultInstance = {
isOk() {
return this.tag === 'Ok'
},
isError() {
return this.tag !== 'Ok'
},
isNoExport() {
return this.tag === 'NoExport'
},
isCodeThrew() {
return this.tag === 'CodeThrew'
},
}
const okResult = <X extends FeatureBase>(feature: X): LoadFeatureCodeResultOk<X> =>
lodash.create(resultProto, {
tag: 'Ok' as const,
feature,
const createCodeSandbox = (): CodeSandbox => {
// 需要被注入到 `sandbox` 中的键值对
const injection = new Map([
// 加固,防止逃逸
[Symbol.unscopables, undefined],
['unsafeWindow', unsafeWindow],
] as [keyof any, unknown][])
// 目标代码执行时的全局对象代理
const sandbox = new Proxy(Object.create(null), {
has: () => true,
get: (_, p) => (injection.has(p) ? injection.get(p) : window[p as string]),
set: (_, p, v) => !injection.has(p) && (window[p as string] = v),
})
const noExportResult = lodash.create(resultProto, {
tag: 'NoExport' as const,
})
const codeThrewResult = (thrown: unknown): LoadFeatureCodeResultCodeThrew =>
lodash.create(resultProto, {
tag: 'CodeThrew' as const,
thrown,
})
/**
* feature
*
* @param code
* @returns `Promise` {@link LoadFeatureCodeResult}
*/
const loadFeatureCode = async <X extends FeatureBase>(
code: string,
): Promise<LoadFeatureCodeResult<X>> => {
// 收集代码导出值
const exports = {}
let result: X
const codeKey = 'BILIBILI_EVOLVED_LOAD_FEATURE_CODE_CODE_KEY_3B63D912__'
// eslint-disable-next-line no-new-func
const fn = Function(
'window',
`with (window) {
return eval(${codeKey})
}`,
).bind(undefined, sandbox)
return {
run(code) {
injection.set('exports', {})
injection.set(codeKey, code)
let returned
try {
result = eval(code)
} catch (thrown) {
return codeThrewResult(thrown)
returned = fn()
} catch (e) {
throw new LoadFeatureCodeError(undefined, { cause: e })
}
const values = Object.values(exports)
if (values.length === 0) {
if (typeof result === 'object') {
return okResult(result)
const exportsValues = Object.values(injection.get('exports'))
const exported = exportsValues.length > 0 ? exportsValues[0] : undefined
return [exported, returned]
},
}
return noExportResult
}
return okResult(values[0] as X)
}
export {
loadFeatureCode,
type LoadFeatureCodeResult,
type LoadFeatureCodeResultCodeThrew,
type LoadFeatureCodeResultError,
type LoadFeatureCodeResultNoExport,
type LoadFeatureCodeResultOk,
let staticCodeSandbox: CodeSandbox | undefined
/**
* feature (component, plugin, style)
*
* @remarks
* feature
* 1. UMD
* 2.
*
* 线
*
* `use strict` feature
*
* `window`访 `unsafeWindow`
*
* @param code -
* @returns feature
* @throws {@link LoadFeatureCodeError}
*
*/
export const loadFeatureCode = (code: string): unknown => {
staticCodeSandbox || (staticCodeSandbox = createCodeSandbox())
const [exported, returned] = staticCodeSandbox.run(code)
return exported || returned
}

View File

@ -1,114 +0,0 @@
import type { ComponentMetadata } from '@/components/types'
import type {
LoadFeatureCodeResultError,
LoadFeatureCodeResultOk,
} from '@/core/external-input/load-feature-code'
import { loadFeatureCodeAllSettled } from '@/core/external-input/load-feature-code-all-settled'
import { useScopedConsole } from '@/core/utils/log'
import type { PluginMetadata } from '@/plugins/plugin'
const curConsole = useScopedConsole('@/core/external-input/load-features-from-codes.ts')
export enum FeatureKind {
Component = 'Component',
Plugin = 'Plugin',
}
const logError = (
kind: FeatureKind,
): ((featureName: string, err: LoadFeatureCodeResultError) => void) => {
const prefix = kind === FeatureKind.Component ? 'component' : 'plugin'
return (featureName, err) => {
if (err.isNoExport()) {
curConsole.error(`${prefix} '${featureName}' exports no value, failed to load`)
} else {
curConsole.error(
`${prefix} '${featureName}' throws something when importing, failed to load`,
{ thrown: err.thrown },
)
}
}
}
const reportErrToUser = (featureKind: FeatureKind, errNames: string[]): void => {
type ErrInfo = number | string[]
const emptyErrInfo: () => string[] = () => []
const accErrInfo = (acc: ErrInfo, featureName: string): ErrInfo => {
if (Array.isArray(acc)) {
if (acc.length < 3) {
acc.push(featureName)
return acc
}
return 4
}
return acc + 1
}
const reportErrInfo = async (kind: FeatureKind, info: ErrInfo) => {
const { Toast } = await import('../toast')
const kindName = kind === FeatureKind.Component ? '组件' : '插件'
if (Array.isArray(info)) {
Toast.error(
`${kindName} "${info.join('", "')}" 加载失败。请向我们反馈,以解决此问题。`,
`${kindName}加载失败`,
)
} else {
Toast.error(
`${info}${kindName}加载失败,请向我们反馈,以解决此问题。`,
`${kindName}加载失败`,
)
}
}
const errInfo = errNames.reduce(accErrInfo, emptyErrInfo())
reportErrInfo(featureKind, errInfo)
}
export type FeatureMetadata = ComponentMetadata | PluginMetadata
/**
*
*
* console
*
* `names` `codes`
*
* @param kind
* @param names
* @param codes
* @return
*/
export async function loadFeaturesFromCodes(
kind: FeatureKind.Component,
names: string[],
codes: string[],
): Promise<ComponentMetadata[]>
export async function loadFeaturesFromCodes(
kind: FeatureKind.Plugin,
names: string[],
codes: string[],
): Promise<PluginMetadata[]>
export async function loadFeaturesFromCodes(
kind: FeatureKind,
names: string[],
codes: string[],
): Promise<FeatureMetadata[]> {
const results = await loadFeatureCodeAllSettled<FeatureMetadata>(codes)
const [namedOk, namedErr] = lodash(results)
.map((r, i) => [names[i], r] as const)
.partition(([, r]) => r.isOk())
.value()
// 输出日志
lodash.forEach(namedErr, lodash.spread(logError(kind)))
// 向用户输出错误报告
if (namedErr.length > 0) {
const errNames = namedErr.map(([name]) => name)
reportErrToUser(kind, errNames)
}
return lodash.map(namedOk, ([, r]) => (r as LoadFeatureCodeResultOk<FeatureMetadata>).feature)
}

View File

@ -38,8 +38,8 @@ export const installFeatureFromCode = async (
metadata: FeatureType
message: string
}> => {
const { parseExternalInput } = await import('../core/external-input')
const item = await parseExternalInput<FeatureType>(code)
const { loadFeatureCode } = await import('../core/external-input')
const item = loadFeatureCode(code) as FeatureType
const { type, installer } = (() => {
if (isComponent(item)) {
return {

134
src/core/local-storage.ts Normal file
View File

@ -0,0 +1,134 @@
import { select } from './spin-query'
import { deleteValue, getRandomId } from './utils'
interface CrossOriginMessage {
type: string
id?: string
key?: string
value?: unknown
}
interface CrossOriginMessageListener {
id: string
resolve: (result: unknown) => void
}
interface CrossOriginStorageListener {
id: string
callback: (newValue: string) => void
}
const postMessage = async (message: CrossOriginMessage) => {
const iframe = (await select(
'iframe[src^="https://s1.hdslb.com/bfs/seed/jinkela/short/cols/iframe.html"][data-loaded="true"]',
)) as HTMLIFrameElement
if (!iframe) {
throw new Error('COLS iframe not found')
}
iframe.contentWindow.postMessage(message, 'https://s1.hdslb.com')
}
const messageListeners: CrossOriginMessageListener[] = []
const storageListeners: CrossOriginStorageListener[] = []
window.addEventListener('message', e => {
if (e.data.type === 'COLS_RES') {
const data = e.data as CrossOriginMessage
const { id } = data
const handleMessageListeners = () => {
const listenerIndex = messageListeners.findIndex(it => it.id === id)
if (listenerIndex < 0) {
return
}
const [listener] = messageListeners.splice(listenerIndex, 1)
listener.resolve(data.value)
}
const handleStorageListeners = () => {
const index = storageListeners.findIndex(it => it.id === id)
if (index < 0) {
return
}
storageListeners[index].callback(data.value as string)
}
handleMessageListeners()
handleStorageListeners()
}
})
const wrapMessageApi = <ReturnType = void>(handler: (messageId: string) => void) => {
return () =>
new Promise<ReturnType>(resolve => {
const id = getRandomId(32)
messageListeners.push({
id,
resolve,
})
handler(id)
})
}
/** 跨域 Local Storage (由 b 站官方提供) */
export const crossOriginLocalStorage = {
setItem: (key: string, item: unknown) => {
return wrapMessageApi(id => {
postMessage({
id,
type: 'COLS_SET',
key,
value: item,
})
})()
},
getItem: <ReturnType = string>(key: string) => {
return wrapMessageApi<ReturnType>(id => {
postMessage({
id,
type: 'COLS_GET',
key,
})
})()
},
removeItem: (key: string) => {
return wrapMessageApi(id => {
postMessage({
id,
type: 'COLS_RM',
key,
})
})()
},
keys: (prefix: string) => {
return wrapMessageApi<string[]>(id => {
postMessage({
id,
type: 'COLS_KEYS',
key: prefix,
})
})()
},
clear: (prefix: string) => {
return wrapMessageApi(id => {
postMessage({
id,
type: 'COLS_CLR',
key: prefix,
})
})()
},
addStorageListener: async (key: string, callback: CrossOriginStorageListener['callback']) => {
const callbackId = getRandomId(32)
storageListeners.push({
id: callbackId,
callback,
})
postMessage({
type: 'COLS_LIS',
key,
id: callbackId,
})
return callbackId
},
removeStorageListener: async (key: string, callbackId: string) => {
deleteValue(storageListeners, it => it.id === callbackId)
postMessage({
type: 'COLS_LIS_UN',
key,
id: callbackId,
})
},
}

View File

@ -29,7 +29,7 @@ export class RuntimeLibrary<LibraryType> implements PromiseLike<LibraryType> {
;(function runEval() {
return eval(code)
// eslint-disable-next-line no-extra-bind
}.bind(window)())
}).bind(window)()
return getModule(window)
})()
}

View File

@ -134,9 +134,8 @@ export const preloadStyles = lodash.once(async () => {
const { initColors } = await import('./theme-color')
const { initMdiStyle } = await import('@/ui/mdi')
document.head.appendChild(initColors())
const [mdi, webFont] = initMdiStyle()
const mdi = initMdiStyle()
document.head.appendChild(mdi)
document.head.appendChild(webFont)
})
})

View File

@ -53,8 +53,7 @@ export default defineComponent({
async mounted() {
await this.$nextTick()
const appendTarget = containerMap[this.container]
this.toast = createMiniToast(this.message, this.content, {
content: this.toastRef,
this.toast = createMiniToast(this.toastRef, this.content, {
placement: this.placement,
showOnCreate: this.show,
trigger: 'mouseenter focusin',

View File

@ -449,6 +449,7 @@ export class DoubleClickEvent {
singleClickHandler: (e: MouseEvent) => void = none
private clickedOnce = false
// eslint-disable-next-line class-methods-use-this
private readonly stopPropagationHandler = (e: MouseEvent) => {
e.stopImmediatePropagation()
}

View File

@ -1,6 +1,6 @@
import type { FeatureBase } from '@/components/types'
import { parseExternalInput } from './external-input'
import { loadFeatureCode } from './external-input'
import { meta } from './meta'
export enum CompareResult {
@ -52,7 +52,7 @@ export class Version {
export const isFeatureAcceptable = async (feature: FeatureBase | string) => {
try {
if (typeof feature === 'string') {
feature = await parseExternalInput<FeatureBase>(feature)
feature = loadFeatureCode(feature) as FeatureBase
}
// 无效代码
if (feature === null || feature === undefined) {

View File

@ -87,6 +87,7 @@ export const plugin: PluginMetadata = {
action: () => {
window.open(it.link(match), '_blank')
},
order: 0,
})
if (it.extend) {
const actions = await it.extend(match)

View File

@ -58,10 +58,12 @@ export const plugins: PluginMetadata[] = getBuiltInPlugins()
* @param code
*/
export const installPlugin = async (code: string) => {
const { parseExternalInput } = await import('../core/external-input')
const plugin = await parseExternalInput<PluginMetadata>(code)
if (plugin === null) {
throw new Error('无效的插件代码')
const { loadFeatureCode } = await import('@/core/external-input')
let plugin: PluginMetadata
try {
plugin = loadFeatureCode(code) as PluginMetadata
} catch (e) {
throw new Error('无效的插件代码', e)
}
const { settings } = await import('@/core/settings')
const existingPlugin = settings.userPlugins[plugin.name]
@ -159,22 +161,27 @@ export const loadPlugin = async (plugin: PluginMetadata) => {
*/
export const loadAllPlugins = async (components: ComponentMetadata[]) => {
const { settings, getGeneralSettings } = await import('@/core/settings')
const { loadFeaturesFromCodes, FeatureKind } = await import(
'@/core/external-input/load-features-from-codes'
)
const otherPlugins = lodash(components)
.map(extractPluginFromComponent)
.filter(p => p !== null)
.map(p => p as PluginMetadata)
.concat(
await loadFeaturesFromCodes(
FeatureKind.Plugin,
Object.keys(settings.userPlugins),
Object.values(settings.userPlugins).map(p => p.code),
),
)
.value()
plugins.push(...otherPlugins)
const { loadFeatureCode } = await import('@/core/external-input/load-feature-code')
for (const component of components) {
const plugin = extractPluginFromComponent(component)
if (plugin) {
plugins.push(plugin)
}
}
for (const [name, setting] of Object.entries(settings.userPlugins)) {
const { code } = setting
let metadata: PluginMetadata
try {
metadata = loadFeatureCode(code) as PluginMetadata
} catch (e) {
console.error('从代码加载用户插件失败。代码可能包含语法错误或执行时产生了异常', {
pluginName: name,
error: e,
})
continue
}
plugins.push(metadata)
}
return Promise.allSettled(plugins.map(loadPlugin)).then(async () => {
if (getGeneralSettings().devMode) {
const { pluginLoadTime, pluginResolveTime } = await import('@/core/performance/plugin-trace')

View File

@ -31,9 +31,9 @@ export const styles: Required<UserStyle>[] = reactive(Object.values(settings.use
export const installStyle = async (input: UserStyle | string) => {
try {
let userStyle: UserStyle
const { parseExternalInput } = await import('../core/external-input')
const { loadFeatureCode } = await import('@/core/external-input')
if (typeof input === 'string') {
userStyle = await parseExternalInput<UserStyle>(input)
userStyle = loadFeatureCode(input) as UserStyle
} else {
userStyle = input
}
@ -61,8 +61,8 @@ export const installStyle = async (input: UserStyle | string) => {
metadata: userStyle,
message: `已安装样式'${displayName || name}'`,
}
} catch (error) {
throw new Error('无效的样式代码')
} catch (e) {
throw new Error('无效的样式代码', { cause: e })
}
}
/**

View File

@ -1,27 +1,33 @@
import { meta } from '@/core/meta'
import mdiStyle from './mdi.css'
export const initMdiStyle = () => {
const mdi = document.createElement('link')
mdi.rel = 'stylesheet'
mdi.href = meta.compilationInfo.altCdn.library.mdi
mdi.media = 'none'
// const mdi = document.createElement('link')
// mdi.rel = 'stylesheet'
// mdi.href = meta.compilationInfo.altCdn.library.mdi
// mdi.media = 'none'
const webFont = document.createElement('link')
webFont.rel = 'preload'
webFont.href = meta.compilationInfo.altCdn.library.mdi.replace(/\.css$/, '.woff2')
webFont.as = 'font'
webFont.type = 'font/woff2'
webFont.crossOrigin = 'anonymous'
// const webFont = document.createElement('link')
// webFont.rel = 'preload'
// webFont.href = meta.compilationInfo.altCdn.library.mdi.replace(/\.css$/, '.woff2')
// webFont.as = 'font'
// webFont.type = 'font/woff2'
// webFont.crossOrigin = 'anonymous'
const webFontPromise = new Promise<void>(resolve => {
webFont.onload = () => resolve()
})
const cssPromise = new Promise<void>(resolve => {
mdi.onload = () => resolve()
})
Promise.allSettled([cssPromise, webFontPromise]).then(() => {
mdi.media = 'all'
})
// const webFontPromise = new Promise<void>(resolve => {
// webFont.onload = () => resolve()
// })
// const cssPromise = new Promise<void>(resolve => {
// mdi.onload = () => resolve()
// })
// Promise.allSettled([cssPromise, webFontPromise]).then(() => {
// mdi.media = 'all'
// })
return [mdi, webFont]
// return [mdi, webFont]
const mdi = document.createElement('style')
mdi.id = 'be-mdi'
mdi.innerHTML = mdiStyle
return mdi
}

1
src/ui/mdi/mdi.css Normal file

File diff suppressed because one or more lines are too long

BIN
src/ui/mdi/mdi.woff2 Normal file

Binary file not shown.