Merge branch 'preview-fixes'

This commit is contained in:
the1812 2024-01-18 09:28:57 +08:00
commit 3981ed7b68
90 changed files with 2393 additions and 827 deletions

View File

@ -28,7 +28,7 @@
"name": "Install dependencies",
"uses": "pnpm/action-setup@v2.2.4",
"with": {
"version": "8.9.0",
"version": "8.12.1",
"run_install": true
}
},

View File

@ -17,7 +17,7 @@
"name": "Install dependencies",
"uses": "pnpm/action-setup@v2.2.4",
"with": {
"version": "8.9.0",
"version": "8.12.1",
"run_install": true
}
},

View File

@ -1,6 +1,75 @@
<!-- spell-checker: disable -->
# 更新日志
## v2.8.8 / v2.8.8-preview
`2024-01-18`
<details>
<summary>正式版用户将获得 v2.8.6-preview ~ v2.8.7-preview 的所有新功能, 点击展开查看</summary>
✨新增
- 新增插件 `下载视频 - MPV 输出支持加强版`. (PR #4448 by [weapon!](https://github.com/Asukaaaaaa))
> 同时支持单文件和多文件, 不需要额外下载程序处理 mpv 协议, 配置方式请参考 [README](https://github.com/Asukaaaaaa/tricks/blob/main/Bilibili-Evolved%20mpv-ex%20%E6%8F%92%E4%BB%B6.md)
- `查看封面` 组件安装后可为 `下载视频` 提供下载封面支持. (#889, PR #4473 by [Oxygenくん](https://github.com/oxygenkun))
- 新增插件 `下载视频 - WASM 混流输出`. (PR #4521 by [WakelessSloth56](https://github.com/WakelessSloth56))
> 使用 WASM 在浏览器中下载并合并音视频,运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件
- 使用流量计费网络时, 不再触发本体和功能的更新检查. (仅 Chrome) (#4477)
- `快捷键扩展` 的音量调整支持自定义幅度. (#2594)
- `自定义字体` 改善了组件代码和功能. (PR #4485 by [Tinhone](https://github.com/Tinhone))
☕开发者相关
- PlayerAgent API 重构为调用 `window.player``window.nano` API, 可以解决 #2544 的问题, 感谢 [timongh](https://github.com/timongh) 和 [WakelessSloth56](https://github.com/WakelessSloth56) 的发现. (#4330, #4341)
</details>
⚠ 注意
- Firefox 的最低版本要求提升至 121, 顺便更新了下配置要求, 分为了最低配置和推荐配置, CPU 统一为桌面级型号.
- 移除对旧版视频播放器的支持, 仅支持新版视频播放器 (BPX 播放器)
✨新增
- 新增插件 `动态过滤器 - 移除充电专属动态`. (#4033)
> 移除动态里的充电专属动态, 装有 `动态过滤器` 时生效.
- `删除视频弹窗` 支持屏蔽 "心动" 弹窗, `禁用特殊弹幕样式` 支持屏蔽相应的带货弹幕. (#4565)
- `删除广告` 支持屏蔽首页的桌面端弹窗广告. (#4590)
- 新增插件 `下载视频 - 空输出`. (#4581)
> 提供一个 "空" 的输出选项, 只想获取下载视频的附带产物 (弹幕, 字幕等) 时可以使用此插件.
- `下载视频` 支持下载合集. (#3240)
- 视频相关功能和快捷键扩展支持新版番剧播放器. (#4571)
🐛修复
- 修复 `评论区IP属地显示` 在小黑屋中失效的问题. (#4572, PR #4573 by [Light_Quanta](https://github.com/LightQuanta))
- `自定义字体` 改进: (PR #4585 by [Tinhone](https://github.com/Tinhone))
- 修复 `自定义字体` 导致快速收藏图标消失. (#4566)
- 将 `覆盖选项` 迁移到了更多选项中.
- 因为文泉驿微米黑WenQuanYi Micro Hei字体的部分版本存在朝鲜语韩语显示不正确的问题所以从默认设置中删除使用 Malgun Gothic 字体替换.
- 夜间模式适配播放页的充电卡片. (#4599, PR #4600 by [Pencil](https://github.com/pencilqaq))
- `直播信息扩充` 使用新的 API, 能够显示悄悄关注的主播. (PR #4587 by [Oxygenくん](https://github.com/oxygenkun))
- 修复自定义顶栏在搜索页面的表现: 关键词同步 & 禁用全局固定. (#1431)
- `删除广告` 使用 CSS `:has` 功能实现了移除广告的空白占位区域. (#3997)
- 修复新版的动态多图界面中 `图片批量导出` 检测不到图片. (#4586)
- 修复使用 `pluginApis.installStyle` 安装带有特殊字符的名称的样式后, 无法删除的问题. (#4557)
- 修复快速收起评论遮挡了评论的操作菜单. (#4595)
- 更新了 `稍后再看重定向` 的说明文字, 明确了功能作用范围. (#4555)
> 将稍后再看的链接重定向为普通播放网址.
> - `重定向页面`: 对稍后再看列表页面里的链接重定向.
> - `重定向顶栏`: 对 `自定义顶栏` 里的稍后再看链接重定向.
- 修复文件命名格式中 `[title]`, `[ep]`, `[lid]` 变量的值获取不正确. (#4575)
☕开发者相关
- pnpm 更新至 8.12.1.
- README 调整了一些中英文字符之间的空格. (PR #4602 by [Shen-Linwood](https://github.com/Shen-Linwood))
- PlayerAgent.getPlayerConfig 支持 BPX 播放器, 且默认认为当前 Agent 为 BPX 类型.
- 修复 DownloadPackage 中同名文件会互相覆盖. (#4576)
- 动态过滤器中使用 CSS `.plugin-block` 可以隐藏某个动态卡片, 插件结合 `forEachFeedsCard` 等 API 可以实现自定义的过滤逻辑. (#4033)
- retrieveImageUrl 支持传入 picture 元素本身.
- 内置 UA 更新至 Firefox 123.
- 增加了 simulateClick API, 可以模拟一次点击. (依次触发 `pointerdown`, `mousedown`, `pointerup`, `mouseup`, `click` 事件)
## v2.8.7 / v2.8.7-preview
`2023-11-23`
@ -46,7 +115,7 @@
- 修复 `自定义顶栏` 历史面板无法正确跳转课程. (#4484)
- 修复 `自定义顶栏` 在历史记录页面产生的布局错位. (#4459)
- 修复 `网址参数清理` 处理相对路径 URL 时产生了错误的结果. (#4471)
- 修复 `自定义顶栏` 在专栏页面挡住了作者信息顶栏.
- 修复 `自定义顶栏` 在专栏页面挡住了作者信息顶栏. (#4540)
☕开发者相关
- 锁定了 PR Check 中的 pnpm 版本, 避免 pnpm 更新后出现不兼容 lockfile 导致 PR Check 失败.

View File

@ -42,7 +42,7 @@ pnpm install
// @description Bilibili Evolved (本地)
// @version 300.0
// @author Grant Howard, Coulomb-G
// @copyright 2023, Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)
// @copyright 2024, Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)
// @license MIT
// @match *://*.bilibili.com/*
// @exclude *://*.bilibili.com/*/mobile.html

View File

@ -32,15 +32,12 @@
**注意事项**
- **⚠ 使用正式版 (GitHub 源) 和预览版须翻墙.**
- 默认不预装任何组件, 需要在按[设置](#设置)中的说明添加感兴趣的功能.
- 默认不预装任何组件, 需要在按[设置](#设置)中的说明添加感兴趣的功能; 默认会屏蔽双击全屏, 如有需要可以安装 `双击全屏` 组件来恢复.
- 这个是一个综合性的脚本, 如果只是想下载视频建议去 [GreasyFork](https://greasyfork.org/zh-CN/scripts/by-site/bilibili.com) 找个更专业的.
- 全景视频不能用鼠标拖拽视角(只能用键盘操作).
- 默认屏蔽双击全屏, 如有需要可以安装 `双击全屏` 组件来恢复.
- 对性能有较大影响.
- 对性能有较大影响, 详见[配置要求](#配置).
- 默认不对未登录的状态做适配.
- 新版本一旦正式发布, 就不再对旧版本做任何技术支持.
- 使用外部网站的链接时 (如将下载任务发送到自己的服务器 / 使用链接安装组件等) 可能会提示"脚本试图访问跨域资源", 请选择"始终允许".
- 需要在至少 1400 x 800 以上的逻辑分辨率下使用.
- 使用外部网站的链接时 (如将下载任务发送到自己的服务器 / 使用链接安装组件等) 可能会提示"脚本试图访问跨域资源", 请选择"始终允许" 避免下次还提示.
| 正式版 (jsDelivr 源) | 正式版 (GitHub 源) | 预览版 |
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
@ -73,16 +70,27 @@
<img alt="about-panel" src="images/v2/about-panel.jpg" width="450">
# 推荐配置
- 操作系统: 64-bit Windows 10 / macOS Sonoma
- 分辨率: 3840x2160 / 192ppi
- 浏览器: Chrome 100+ / Firefox 103+ / Safari 15.4+ (仅理论上能运行, 没测试过)
- 处理器: Intel Core i7-9750H / AMD Ryzen 5 5600H
# 配置
## 最低配置
- 操作系统: Windows 8.1 / macOS Big Sur
- 分辨率: 1920x1080, 缩放 125%
- 浏览器: Chrome 105+ / Firefox 121+ / Safari 15.4+ (仅理论上能运行, 没测试过)
- 处理器: Intel Core i5-10500 / AMD Ryzen 5 3600
- 内存: 8GB
- 脚本管理器: Tampermonkey 4.14 / Violentmonkey 2.13
- 脚本管理器: Tampermonkey 5.0 / Violentmonkey 2.18
- 显卡: GeForce GTX 1050 / Radeon HD 7870
- 网络: 10MB/s
## 推荐配置
- 操作系统: Windows 10 / macOS Sonoma
- 分辨率: 3840x2160, 缩放 200%
- 浏览器: Chrome 120+ / Firefox 121+
- 处理器: Intel Core i7-11700 / AMD Ryzen 7 5800
- 内存: 32GB
- 脚本管理器: Tampermonkey 5.0 / Violentmonkey 2.18
- 显卡: GeForce RTX 3060 / Radeon RX 6600XT
- 网络: 20MB/s
# 兼容性
## 脚本管理器

View File

@ -29,6 +29,11 @@ https://afdian.net/@the1812?tab=sponsor
| 时间 | 用户名 | 单号后4位 | 金额 |
| ------------------- | --------------------- | --------- | ------- |
| 2024.01.12 22:02:24 | 简式谐_LastofNESTS | 5700 | ¥22.33 |
| 2024.01.02 16:44:38 | O*h | 5101 | ¥10.00 |
| 2023.12.18 21:46:53 | *京 | 1206 | ¥20.00 |
| 2023.12.12 19:57:57 | *克 | 0688 | ¥2.33 |
| 2023.11.30 09:23:38 | *漩 | 2405 | ¥5.00 |
| 2023.11.22 15:55:16 | *楠 | 2917 | ¥30.00 |
| 2023.10.30 11:02:52 | *分 | 9485 | ¥3.00 |
| 2023.10.17 09:38:26 | f*t | 0728 | ¥10.00 |

View File

@ -195,7 +195,7 @@
"type": "component",
"name": "customFontFamily",
"displayName": "自定义字体",
"description": "by [@Tinhone](https://github.com/Tinhone)\n\n使用组件提供的字体设置覆盖原版的主站字体并使主站字体可被自定义。字体设置写法请参考 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/CSS/font-family) 、默认设置与设置说明",
"description": "by [@Tinhone](https://github.com/Tinhone)\n\n使用组件提供的字体设置覆盖原版的主站字体并使主站字体可被自定义。\r\n\r\n当组件被启用后几乎所有的元素会立即应用组件提供的字体设置。\r\n\r\n选项说明:\r\n- `禁用标题标点符号缩进`: 在新版视频页中,推荐视频栏中的视频标题,如果首个字符是特定的标点符号,则文本会缩入左侧。这个选项可以禁用这种样式\r\n- `更多选项`:\r\n - `自定义字体`: 设置自定义字体。写法请参考 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/CSS/font-family)、默认设置与设置说明。\r\n - `覆盖选项`: 相当于一个白名单,使用了特殊字体的元素会被加入其中。默认情况下这些元素不会应用组件提供的字体设置,只有在启用对应的选项后才会应用。",
"fullRelativePath": "../../registry/dist/components/style/custom-font-family.js",
"fullAbsolutePath": "registry/dist/components/style/custom-font-family.js"
},
@ -619,7 +619,7 @@
"type": "component",
"name": "watchlaterRedirect",
"displayName": "稍后再看重定向",
"description": "将稍后再看的链接重定向为普通播放网址.",
"description": "将稍后再看的链接重定向为普通播放网址.\r\n- `重定向页面`: 对稍后再看列表页面里的链接重定向.\r\n- `重定向顶栏`: 对 `自定义顶栏` 里的稍后再看链接重定向.\r\n",
"fullRelativePath": "../../registry/dist/components/utils/watchlater-redirect.js",
"fullAbsolutePath": "registry/dist/components/utils/watchlater-redirect.js"
},
@ -904,6 +904,14 @@
"description": "by FoundTheWOUT\n\n在视频播放器右上角显示系统时间.",
"owner": "FoundTheWOUT"
},
{
"type": "plugin",
"name": "feedsFilter.pluginBlocks.chargeFeeds",
"displayName": "动态过滤器 - 移除充电专属动态",
"description": "移除动态里的充电专属动态, 装有 `动态过滤器` 时生效.\r\n",
"fullRelativePath": "../../registry/dist/plugins/feeds/filter/hide-charge-feeds.js",
"fullAbsolutePath": "registry/dist/plugins/feeds/filter/hide-charge-feeds.js"
},
{
"type": "plugin",
"name": "launchBar.actions.audioSearch",
@ -1032,6 +1040,14 @@
"fullRelativePath": "../../registry/dist/plugins/video/download/aria2-output.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/aria2-output.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.empty",
"displayName": "下载视频 - 空输出",
"description": "提供一个 \"空\" 的输出选项, 只想获取下载视频的附带产物 (弹幕, 字幕等) 时可以使用此插件.\r\n",
"fullRelativePath": "../../registry/dist/plugins/video/download/empty-output.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/empty-output.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.idm",
@ -1056,6 +1072,14 @@
"fullRelativePath": "../../registry/dist/plugins/video/download/motrix-output.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/motrix-output.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.mpv-ex",
"displayName": "下载视频 - MPV 输出支持加强版",
"description": "by [@asuaaa](https://github.com/Asukaaaaaa)\n\n为下载视频增加 MPV 输出,支持导出列表, 配置方式请参考 [README](https://github.com/Asukaaaaaa/tricks/blob/main/Bilibili-Evolved%20mpv-ex%20%E6%8F%92%E4%BB%B6.md)",
"fullRelativePath": "../../registry/dist/plugins/video/download/mpv-output-ex.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/mpv-output-ex.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.mpv-playlist",
@ -1072,6 +1096,14 @@
"fullRelativePath": "../../registry/dist/plugins/video/download/mpv-output.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/mpv-output.js"
},
{
"type": "plugin",
"name": "downloadVideo.outputs.wasm",
"displayName": "下载视频 - WASM 混流输出",
"description": "by [@WakelessSloth56](https://github.com/WakelessSloth56)\n\n使用 WASM 在浏览器中下载并合并音视频",
"fullRelativePath": "../../registry/dist/plugins/video/download/wasm-output.js",
"fullAbsolutePath": "registry/dist/plugins/video/download/wasm-output.js"
},
{
"type": "plugin",
"name": "speed.keymap",

View File

@ -242,7 +242,15 @@ by [@RieN7](https://github.com/rien7)
by [@Tinhone](https://github.com/Tinhone)
使用组件提供的字体设置覆盖原版的主站字体,并使主站字体可被自定义。字体设置写法请参考 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/CSS/font-family) 、默认设置与设置说明
使用组件提供的字体设置覆盖原版的主站字体,并使主站字体可被自定义。
当组件被启用后,几乎所有的元素会立即应用组件提供的字体设置。
选项说明:
- `禁用标题标点符号缩进`: 在新版视频页中,推荐视频栏中的视频标题,如果首个字符是特定的标点符号,则文本会缩入左侧。这个选项可以禁用这种样式
- `更多选项`:
- `自定义字体`: 设置自定义字体。写法请参考 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/CSS/font-family)、默认设置与设置说明。
- `覆盖选项`: 相当于一个白名单,使用了特殊字体的元素会被加入其中。默认情况下这些元素不会应用组件提供的字体设置,只有在启用对应的选项后才会应用。
### [自定义顶栏](../../registry/dist/components/style/custom-navbar.js)
`customNavbar`
@ -821,6 +829,8 @@ by [@Light_Quanta](https://github.com/LightQuanta)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/watchlater-redirect.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/watchlater-redirect.js)
将稍后再看的链接重定向为普通播放网址.
- `重定向页面`: 对稍后再看列表页面里的链接重定向.
- `重定向顶栏`: 对 `自定义顶栏` 里的稍后再看链接重定向.
### [自动移出稍后再看](../../registry/dist/components/video/auto-remove-watchlater.js)
`autoRemoveWatchlater`
@ -1242,6 +1252,15 @@ by FoundTheWOUT
在视频播放器右上角显示系统时间.
## 插件
### [动态过滤器 - 移除充电专属动态](../../registry/dist/plugins/feeds/filter/hide-charge-feeds.js)
`feedsFilter.pluginBlocks.chargeFeeds`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/feeds/filter/hide-charge-feeds.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/feeds/filter/hide-charge-feeds.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/feeds/filter/hide-charge-feeds.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/feeds/filter/hide-charge-feeds.js)
移除动态里的充电专属动态, 装有 `动态过滤器` 时生效.
### [搜索栏 - 音频跳转](../../registry/dist/plugins/launch-bar/audio-search.js)
`launchBar.actions.audioSearch`
@ -1388,6 +1407,15 @@ by FoundTheWOUT
为下载视频增加 aria2 文件导出和 RPC 输出支持.
### [下载视频 - 空输出](../../registry/dist/plugins/video/download/empty-output.js)
`downloadVideo.outputs.empty`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/download/empty-output.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/video/download/empty-output.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/video/download/empty-output.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/video/download/empty-output.js)
提供一个 "空" 的输出选项, 只想获取下载视频的附带产物 (弹幕, 字幕等) 时可以使用此插件.
### [下载视频 - IDM 输出支持](../../registry/dist/plugins/video/download/idm-output.js)
`downloadVideo.outputs.idm`
@ -1415,6 +1443,17 @@ by FoundTheWOUT
为下载视频增加 Motrix 输出支持.
### [下载视频 - MPV 输出支持加强版](../../registry/dist/plugins/video/download/mpv-output-ex.js)
`downloadVideo.outputs.mpv-ex`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/download/mpv-output-ex.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/video/download/mpv-output-ex.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/video/download/mpv-output-ex.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/video/download/mpv-output-ex.js)
by [@asuaaa](https://github.com/Asukaaaaaa)
为下载视频增加 MPV 输出,支持导出列表, 配置方式请参考 [README](https://github.com/Asukaaaaaa/tricks/blob/main/Bilibili-Evolved%20mpv-ex%20%E6%8F%92%E4%BB%B6.md)
### [下载视频 - MPV 播放支持(列表)](../../registry/dist/plugins/video/download/mpv-output-playlist.js)
`downloadVideo.outputs.mpv-playlist`
@ -1437,6 +1476,17 @@ by [@diannaojiang](https://github.com/diannaojiang)
为下载视频增加 MPV 输出支持, 配置方式请参考 [Bilibili-Playin-Mpv](https://github.com/diannaojiang/Bilibili-Playin-Mpv)
### [下载视频 - WASM 混流输出](../../registry/dist/plugins/video/download/wasm-output.js)
`downloadVideo.outputs.wasm`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/download/wasm-output.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/video/download/wasm-output.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/video/download/wasm-output.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/video/download/wasm-output.js)
by [@WakelessSloth56](https://github.com/WakelessSloth56)
使用 WASM 在浏览器中下载并合并音视频
### [快捷键扩展 - 视频倍速](../../registry/dist/plugins/video/player/speed.js)
`speed.keymap`

View File

@ -109,20 +109,65 @@ export default Vue.extend({
try {
this.items = []
this.loaded = false
const pageSize = 24
const [promise] = responsiveGetPages<LiveInfo>({
const [, promise] = responsiveGetPages<LiveInfo>({
api: page =>
getJsonWithCredentials(
`https://api.live.bilibili.com/relation/v1/feed/feed_list?page=${page}&pagesize=${pageSize}`,
`https://api.live.bilibili.com/xlive/web-ucenter/v1/xfetter/GetWebList?page=${page}`,
),
getList: json => lodash.get(json, 'data.list', []),
getTotal: json => lodash.get(json, 'data.results', 0),
getList: json =>
lodash.get(json, 'data.list', []).map(item => {
const { face, uname, title, room_id, cover_from_user, online, uid, link } = item
return {
cover: face,
face,
uname,
title,
roomid: room_id,
pic: cover_from_user,
online,
uid,
link,
}
}),
getTotal: json => lodash.get(json, 'data.count', 0),
})
this.items = await promise
const [allItems, recommendItems] = await Promise.all([promise, this.fetchRecommendItems()])
this.items = this.sortByRecommend(allItems, recommendItems)
} finally {
this.loaded = true
}
},
async fetchRecommendItems(): Promise<LiveInfo[]> {
// portaltop30up
const portalList = await getJsonWithCredentials(
'https://api.bilibili.com/x/polymer/web-dynamic/v1/portal',
)
const recommendLiveItems = portalList.data.live_users.items.map(item => {
const { jump_url, room_id, face, title, uname, mid } = item
return {
cover: face,
face,
uname,
title,
roomid: room_id,
pic: '', // portal
online: 0, // portal
uid: mid,
link: jump_url,
}
})
return recommendLiveItems
},
sortByRecommend(feedItems: LiveInfo[], recommendItems: LiveInfo[]) {
// recommendItemspiconlineui
// 便recommendItemsitem
const recommendRoomIds = recommendItems.map(item => item.roomid)
const feedConcatItems = feedItems.filter(item => !recommendRoomIds.includes(item.roomid))
return lodash.concat(recommendItems, feedConcatItems)
},
},
})
</script>
@ -136,96 +181,119 @@ export default Vue.extend({
font-size: 12px;
color: #000;
background-color: #fff;
body.dark & {
background-color: #444;
color: #eee;
}
&-header {
@include h-center();
justify-content: space-between;
}
&-title {
@include h-center(4px);
font-size: 14px;
}
&-count {
opacity: 0.5;
font-size: 12px;
}
&-actions {
@include h-center(4px);
}
&-refresh {
cursor: pointer;
padding: 2px;
.be-icon {
opacity: 0.5;
transition: 0.35s ease-out;
}
&:hover .be-icon {
transform: rotate(360deg);
opacity: 1;
color: var(--theme-color);
}
}
&-more {
opacity: 0.5;
padding: 2px;
cursor: pointer;
@include h-center();
&:hover {
opacity: 1;
color: var(--theme-color);
}
}
&-search {
@include h-center(8px);
.be-icon {
opacity: 0.6;
}
.be-textbox {
flex-grow: 1;
}
}
&-content {
@include no-scrollbar();
@include v-stretch(8px);
//
max-height: calc(100vh - 358px);
//
body.enable-feeds-filter & {
max-height: calc(100vh - 414px);
}
// profile
body.feeds-filter-side-block-profile & {
max-height: calc(100vh - 218px);
}
}
&-item {
@include h-center(8px);
cursor: pointer;
&-avatar {
overflow: hidden;
border-radius: 50%;
display: flex;
border: 1px solid #8884;
}
&-info {
@include v-stretch(2px);
flex-grow: 1;
width: 0;
}
&-title,
&-user {
@include single-line();
}
&-title {
font-size: 14px;
transition: 0.2s ease-out;
}
&-user {
opacity: 0.6;
}
&:hover &-title {
color: var(--theme-color);
}

View File

@ -227,6 +227,7 @@ body.enable-feeds-filter:not(.disable-feeds-filter) {
@include type-block();
@include side-block();
@include pattern-block();
@include plugin-block();
}
body.disable-feeds-filter {
.feeds-filter-section {

View File

@ -107,3 +107,9 @@
display: none !important;
}
}
@mixin plugin-block {
.bili-dyn-list__item.plugin-block,
.feed-card .card.plugin-block {
display: none !important;
}
}

View File

@ -35,7 +35,8 @@
font-size: 13px;
}
.at-panel,
.emoji-panel {
.emoji-panel,
.reply-operation .operation-list {
z-index: 111;
}
}

View File

@ -0,0 +1,20 @@
export const fontFamilyDefaultValue =
'Microsoft Yahei, Helvetica Neue, Helvetica, Arial, Hiragino Sans GB, Heiti SC, Malgun Gothic'
export const coverOptionsName = [
{ camel: 'coverOrnament', kebab: 'cover-ornament', display: '装扮字体' },
{ camel: 'coverFansMedal', kebab: 'cover-fans-medal', display: '粉丝勋章字体' },
{ camel: 'coverDanmaku', kebab: 'cover-danmaku', display: '弹幕字体' },
{ camel: 'coverIconFont', kebab: 'cover-icon-font', display: '图标字体' },
{ camel: 'coverColumn', kebab: 'cover-column', display: '专栏自定义字体' },
{ camel: 'coverScore', kebab: 'cover-score', display: '评分字体' },
]
export const coverOptionsDefaultValue = {
coverOrnament: false,
coverFansMedal: false,
coverDanmaku: false,
coverIconFont: false,
coverColumn: false,
coverScore: false,
}

View File

@ -1,16 +0,0 @@
$name: 'custom-font-family';
html[#{$name}--options--disable-quotation-mark-text-indent='true']
:is(
p[title^=''],
p[title^=''],
p[title^=''],
h1[title^=''],
h1[title^=''],
h1[title^=''],
h3[title^=''],
h3[title^=''],
h3[title^='']
) {
text-indent: initial !important;
}

View File

@ -0,0 +1,26 @@
$name: 'custom-font-family';
html[#{$name}--options--disable-title-punctuation-text-indent='true']
:is(
p[title^=''],
p[title^=''],
p[title^=''],
h1[title^=''],
h1[title^=''],
h1[title^=''],
h3[title^=''],
h3[title^=''],
h3[title^=''],
p[title^=''],
h3[title^=''],
p[title^=''],
h3[title^=''],
p[title^=''],
h3[title^=''],
p[title^=''],
h3[title^=''],
p[title^=''],
h3[title^='']
) {
text-indent: initial !important;
}

View File

@ -0,0 +1,33 @@
<template>
<div class="custom-font-family-extra-options-entry">
<VButton @mouseover="loadPanel()" @click="togglePanelDisplay()">
更多选项<VIcon icon="right-arrow" :size="16"></VIcon>
</VButton>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VIcon, VButton } from '@/ui'
import { loadPanel, togglePanelDisplay } from './vm'
export default defineComponent({
components: {
VIcon,
VButton,
},
methods: {
loadPanel,
togglePanelDisplay,
},
})
</script>
<style lang="scss">
.custom-font-family-extra-options-entry {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,81 @@
<template>
<VPopup v-model="popupOpen" class="be-extra-options-panel" fixed :lazy="false">
<div class="be-eop-header">
<div class="be-eop-h-title">
<VIcon class="be-eop-h-t-icon" :icon="initData.header.title.icon" :size="24"></VIcon>
<div class="be-eop-h-t-text">{{ initData.header.title.text }}</div>
</div>
<div class="be-eop-h-actions">
<div v-for="action in initData.header.actions" :key="action.id" class="be-eop-h-a-action">
<VIcon
:ref="`action${action.id}`"
:class="`action-${action.actionClassNameSuffix}`"
:title="action.title"
:icon="action.icon"
:size="24"
></VIcon>
</div>
<div class="be-eop-h-a-action">
<VIcon
class="action-close"
title="关闭"
icon="mdi-close"
:size="24"
@click="popupOpen = false"
></VIcon>
</div>
</div>
</div>
<div class="be-eop-separator"></div>
<div class="be-eop-content">
<div v-for="option in initData.content.options" :key="option.id" class="be-eop-c-option">
<div class="be-eop-c-o-title">{{ option.title }}</div>
<div class="be-eop-c-o-description">{{ option.description }}</div>
<div class="be-eop-c-o-input" :class="`input-${option.inputClassNameSuffix}`">
<slot :name="`input${option.id}`">
选项输入入口默认文字使用含 v-slot 指令的 template 元素以替换默认内容
</slot>
</div>
</div>
</div>
</VPopup>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VPopup, VIcon } from '@/ui'
import { defaultInitData } from './extra-options-panel'
export default defineComponent({
name: 'ExtraOptionsPanel',
components: {
VPopup,
VIcon,
},
props: {
initData: {
type: Object,
default: defaultInitData,
},
},
data() {
return {
popupOpen: false,
}
},
})
</script>
<style lang="scss">
@import './extra-options-panel';
.be-extra-options-panel {
@include extra-options-panel();
}
</style>

View File

@ -0,0 +1,190 @@
<template>
<ExtraOptionsPanel
ref="extraOptionsPanel"
class="custom-font-family-extra-options-panel"
:class="{ peek: isPeeking }"
:init-data="initData"
>
<template #input0>
<TextArea v-model="inputFontFamily" />
</template>
<template #input1>
<SwitchOptionsMin :options="switchOptionsComponentOptions"></SwitchOptionsMin>
</template>
</ExtraOptionsPanel>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { TextArea } from '@/ui'
import SwitchOptionsMin from './SwitchOptionsMin.vue'
import { SwitchMetadataOption } from '@/components/switch-options'
import ExtraOptionsPanel from './ExtraOptionsPanel.vue'
import { getComponentSettings } from '@/core/settings'
import { Toast } from '@/core/toast'
import { delay } from '@/core/utils'
import { useScopedConsole } from '@/core/utils/log'
import { fontFamilyDefaultValue, coverOptionsName, coverOptionsDefaultValue } from '../data'
import { ExtraOptionsPanelInitData } from './extra-options-panel'
const initData: ExtraOptionsPanelInitData = {
header: {
title: {
text: '自定义字体',
icon: 'mdi-format-font',
},
actions: [
{
id: 0,
title: '重置面板中的所有选项为默认值',
icon: 'mdi-cog-sync-outline',
actionClassNameSuffix: 'reset',
},
{
id: 1,
title: '透视',
icon: 'mdi-eye-outline',
actionClassNameSuffix: 'peek',
},
],
},
content: {
options: [
{
id: 0,
title: '自定义字体',
description: '输入需要设置的字体,不同字体之间必须以英文逗号分隔',
inputClassNameSuffix: 'input-font-family',
},
{
id: 1,
title: '覆盖选项',
description: '下面的元素使用了特殊字体,启用选项后可在对应的元素上应用组件提供的字体设置',
inputClassNameSuffix: 'cover-options',
},
],
},
}
const switchOptionsComponentOptions: SwitchMetadataOption<string, string> = {
name: 'customFontFamilyCoverOptions',
switches: {},
radio: false,
dimAt: 'notChecked',
switchProps: {
checkedIcon: 'mdi-checkbox-marked-circle',
notCheckedIcon: 'mdi-checkbox-blank-circle-outline',
},
componentName: 'customFontFamily',
optionDisplayName: '自定义字体覆盖选项',
}
for (const coverOptionName of coverOptionsName) {
switchOptionsComponentOptions.switches[coverOptionName.camel] = {
displayName: coverOptionName.display,
defaultValue: coverOptionsDefaultValue[coverOptionName.camel],
}
}
const console = useScopedConsole('自定义字体')
export default defineComponent({
components: {
ExtraOptionsPanel,
TextArea,
SwitchOptionsMin,
},
data() {
return {
isPeeking: false,
isMouseOverPeekIcon: false,
initData,
// inputFontFamily fontFamily
inputFontFamily: getComponentSettings('customFontFamily').options.fontFamily,
switchOptionsComponentOptions,
}
},
watch: {
// inputFontFamily fontFamily inputFontFamily 1000ms
inputFontFamily: lodash.debounce(value => {
getComponentSettings('customFontFamily').options.fontFamily = value
}, 1000),
async isMouseOverPeekIcon(value: boolean) {
if (!value) {
this.isPeeking = false
return
}
if (value) {
await delay(200)
}
if (this.isMouseOverPeekIcon) {
this.isPeeking = true
}
},
},
mounted() {
// v-for 使
// ref
// https://cn.vuejs.org/guide/essentials/template-refs.html#refs-inside-v-for
const action0Reset = this.$refs.extraOptionsPanel.$refs.action0[0].$el as HTMLElement
action0Reset.addEventListener('click', this.confirmResetOptions)
const action1Peek = this.$refs.extraOptionsPanel.$refs.action1[0].$el as HTMLElement
action1Peek.addEventListener('mouseover', this.setIsMouseOverPeekIconToTrue)
action1Peek.addEventListener('mouseout', this.setIsMouseOverPeekIconToFalse)
},
methods: {
toggleDisplay() {
this.$refs.extraOptionsPanel.popupOpen = !this.$refs.extraOptionsPanel.popupOpen
},
setIsMouseOverPeekIconToTrue() {
this.isMouseOverPeekIcon = true
},
setIsMouseOverPeekIconToFalse() {
this.isMouseOverPeekIcon = false
},
confirmResetOptions() {
if (confirm('确定将面板中的所有选项重置为默认值吗?')) {
this.resetOptions()
}
},
resetOptions() {
//
getComponentSettings('customFontFamily').options.fontFamily = fontFamilyDefaultValue
this.inputFontFamily = fontFamilyDefaultValue
//
for (const coverOptionName of coverOptionsName) {
const defaultValue = coverOptionsDefaultValue[coverOptionName.camel]
getComponentSettings('customFontFamily').options[coverOptionName.camel] = defaultValue
}
Toast.success('更多选项面板中的所有选项已成功被重置为默认值', '自定义字体', 2000)
console.log('更多选项面板中的所有选项已成功被重置为默认值')
},
},
})
</script>
<style lang="scss">
.custom-font-family-extra-options-panel {
&.peek {
opacity: 0.1;
}
> .be-eop-content > .be-eop-c-option > .be-eop-c-o-input.input-input-font-family > .be-text-area {
min-height: 160px;
}
}
</style>

View File

@ -0,0 +1,115 @@
<!-- 这个 Vue 组件是因为无法正常从 @/components/SwitchOptions.vue 导入 SwitchOptions 组件而新建的 -->
<template>
<div class="switch-options-min">
<div class="switch-options-min-grid">
<component
:is="options.radio ? 'RadioButton' : 'CheckBox'"
v-for="name of Object.keys(options.switches)"
:key="name"
:class="{ dim: !(Number(componentOptions[name]) ^ Number(isDimAtChecked)) }"
v-bind="mergedSwitchProps"
:checked="componentOptions[name]"
@change="componentOptions[name] = $event"
>
<!-- dim 这里原来的写法在使用 export default defineComponent 的情况下会报错虽然能运行但还是改了改 -->
{{ options.switches[name].displayName }}
</component>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { CheckBox, RadioButton } from '@/ui'
import { getComponentSettings } from '@/core/settings'
export default defineComponent({
name: 'SwitchOptionsMin',
components: {
CheckBox,
RadioButton,
},
props: {
options: {
type: Object,
required: true,
},
},
data() {
const { componentName } = this.options
const componentOptions = getComponentSettings(componentName).options
const getIsDimAtChecked = () => {
if (this.options.dimAt === 'checked' || this.options.dimAt === undefined) {
return true
}
if (this.options.dimAt === 'notChecked') {
return false
}
return false
}
const isDimAtChecked = getIsDimAtChecked()
return {
componentOptions,
isDimAtChecked,
}
},
computed: {
mergedSwitchProps() {
return {
checkedIcon: 'mdi-eye-off-outline',
notCheckedIcon: 'mdi-eye-outline',
...this.options.switchProps,
}
},
},
watch: {
options() {
this.updateColumnsCount()
},
},
mounted() {
this.updateColumnsCount()
},
methods: {
updateColumnsCount() {
const element = this.$el as HTMLElement
const columns = Math.ceil(Object.keys(this.options.switches).length / 12)
element.style.setProperty('--columns', columns.toString())
},
},
})
</script>
<style lang="scss">
@import 'common';
.switch-options-min {
position: relative;
--columns: 1;
width: 100%;
.switch-icon {
margin-right: 8px;
transform: scale(0.9);
}
.dim {
opacity: 0.5;
}
& > &-grid {
font-size: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, 50%);
}
}
</style>

View File

@ -1,45 +0,0 @@
<template>
<div class="custom-font-family-extra-options-entry">
<VButton ref="button" @mouseover="loadExtraOptions()" @click="toggleExtraOptionsDisplay()">
字体设置<VIcon icon="right-arrow" :size="16"></VIcon>
</VButton>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VIcon, VButton } from '@/ui'
import {
setTriggerElement,
getExtraOptionsLoadState,
loadExtraOptions,
toggleExtraOptionsDisplay,
} from './vm'
export default defineComponent({
components: {
VIcon,
VButton,
},
methods: {
async loadExtraOptions() {
const isLoaded = await getExtraOptionsLoadState()
if (!isLoaded) {
await loadExtraOptions()
const triggerButton = this.$refs.button.$el as HTMLElement
setTriggerElement(triggerButton)
}
},
toggleExtraOptionsDisplay,
},
})
</script>
<style lang="scss">
.custom-font-family-extra-options-entry {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -1,59 +1,52 @@
@import 'common';
$pref: 'eop';
$prefH: 'eop-h';
$prefHL: 'eop-h-l';
$prefHR: 'eop-h-r';
$prefC: 'eop-c';
$prefCS: 'eop-c-s';
$panelPadding: 22px;
$pref: 'be-eop';
$panel-padding: 18px;
$internal-spacing: 14px;
@mixin extra-options-panel {
&.extra-options-panel {
&.be-extra-options-panel {
@include popup();
display: flex;
flex-direction: column;
width: 500px;
max-width: 80vw;
max-height: 80vh;
padding: $panelPadding 0;
padding: $panel-padding 0;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%) scale(0.95);
transition: all 0.2s ease-out;
font-size: 14px;
line-height: normal;
z-index: 100002;
&.open {
transform: translateX(-50%) translateY(-50%) scale(1);
}
&.peek {
opacity: 0.1;
}
> .#{$pref}-header {
display: flex;
justify-content: space-between;
padding: 0 $panelPadding 18px $panelPadding;
padding: 0 $panel-padding $internal-spacing $panel-padding;
flex-shrink: 0;
> .#{$prefH}-left {
> .#{$pref}-h-title {
display: flex;
column-gap: 4px;
> .#{$prefHL}-title {
> .#{$pref}-h-t-text {
@include semi-bold();
font-size: 19px;
line-height: 20px;
}
}
> .#{$prefH}-right {
> .#{$pref}-h-actions {
display: flex;
column-gap: 20px;
column-gap: ($internal-spacing + 4px);
> [class*='#{$prefHR}'] {
> .#{$pref}-h-a-action {
cursor: pointer;
transition: all 0.2s ease-out;
@ -66,40 +59,41 @@ $panelPadding: 22px;
> .#{$pref}-separator {
height: 1px;
margin: 0 $panelPadding;
background: rgba(0, 0, 0, 0.1);
margin: 0 $panel-padding;
background-color: rgba(0, 0, 0, 0.1);
flex-shrink: 0;
body.dark & {
background: rgba(255, 255, 255, 0.1);
background-color: rgba(255, 255, 255, 0.1);
}
}
> .#{$pref}-content {
padding: 0 $panelPadding;
@include no-scrollbar();
padding: $internal-spacing $panel-padding 0 $panel-padding;
overflow: auto;
flex-grow: 1;
flex-shrink: 1;
> .#{$prefC}-section {
margin: 18px 0;
> .#{$pref}-c-option {
margin-bottom: ($internal-spacing + 4px);
&:nth-child(1):nth-last-child(1) {
margin-bottom: 12px;
&:nth-last-child(1) {
margin-bottom: ($internal-spacing - 2px);
}
> .#{$prefCS}-title {
> .#{$pref}-c-o-title {
margin-bottom: 2px;
font-size: 14px;
}
> .#{$prefCS}-description {
> .#{$pref}-c-o-description {
margin-bottom: 8px;
opacity: 0.6;
font-size: 12px;
}
> .#{$prefCS}-input {
> .#{$pref}-c-o-input {
@include h-center();
flex-wrap: wrap;
}

View File

@ -0,0 +1,72 @@
export interface ExtraOptionsPanelInitData {
header: {
title: {
/** 标题文字 */
text: string
/** 标题图标 */
icon: string
}
actions: {
/** 动作序号,从 0 开始增长,重复将会导致渲染异常 */
id: number
/** 动作标题 */
title: string
/** 动作图标 */
icon: string
/** 动作类名的后缀,前缀是 action- */
actionClassNameSuffix: string
}[]
}
content: {
options: {
/** 选项序号,从 0 开始增长,重复将会导致渲染异常 */
id: number
/** 选项标题 */
title: string
/** 选项介绍 */
description: string
/** 选项输入入口类名的后缀,前缀是 input- */
inputClassNameSuffix: string
}[]
}
}
// 当使用 ExtraOptionsPanel 组件时,如果不传入 initData 所使用的默认数据
export const defaultInitData: ExtraOptionsPanelInitData = {
header: {
title: {
text: '默认标题',
icon: 'mdi-format-font',
},
actions: [
{
id: 0,
title: '默认动作 0',
icon: 'mdi-cog-sync-outline',
actionClassNameSuffix: 'default-action-0',
},
{
id: 1,
title: '默认动作 1',
icon: 'mdi-eye-outline',
actionClassNameSuffix: 'default-action-1',
},
],
},
content: {
options: [
{
id: 0,
title: '默认选项 0',
description: '默认选项 0 的说明',
inputClassNameSuffix: 'default-option-0',
},
{
id: 1,
title: '默认选项 1',
description: '默认选项 1 的说明',
inputClassNameSuffix: 'default-option-1',
},
],
},
}

View File

@ -1,136 +0,0 @@
<template>
<VPopup
ref="popup"
v-model="popupOpen"
class="custom-font-family-extra-options-panel extra-options-panel"
fixed
:lazy="false"
:trigger-element="triggerElement"
:class="{ peek: isPeeking }"
>
<div class="eop-header">
<div class="eop-h-left">
<VIcon class="eop-h-l-symbol" icon="mdi-format-font" :size="24"></VIcon>
<div class="eop-h-l-title">字体设置</div>
</div>
<div class="eop-h-right">
<VIcon
class="eop-h-r-reset"
title="重置面板中的所有选项为默认值"
icon="mdi-cog-sync-outline"
:size="24"
@click="confirmResetOptions()"
></VIcon>
<VIcon
class="eop-h-r-peek"
title="透视"
icon="mdi-eye-outline"
:size="24"
@mouseover="mouseOnPeekIcon = true"
@mouseout="mouseOnPeekIcon = false"
></VIcon>
<VIcon
class="eop-h-r-close"
title="关闭"
icon="mdi-close"
:size="24"
@click="popupOpen = false"
></VIcon>
</div>
</div>
<div class="eop-separator"></div>
<div class="eop-content">
<div class="eop-c-section">
<div class="eop-c-s-title">自定义字体</div>
<div class="eop-c-s-description">输入字体不同字体之间必须以英文逗号分隔</div>
<div class="eop-c-s-input input-font-family">
<TextArea v-model="inputFontFamily" />
</div>
</div>
</div>
</VPopup>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { VPopup, VIcon, TextArea } from '@/ui'
import { getComponentSettings } from '@/core/settings'
import { delay } from '@/core/utils'
import { useScopedConsole } from '@/core/utils/log'
import { fontFamilyDefaultValue } from '../font-family-default-value'
export default defineComponent({
components: {
VPopup,
VIcon,
TextArea,
},
props: {
triggerElement: {
type: HTMLElement,
default: null,
},
},
data() {
return {
popupOpen: false,
isPeeking: false,
mouseOnPeekIcon: false,
// inputFontFamily fontFamily
inputFontFamily: getComponentSettings('customFontFamily').options.fontFamily,
}
},
watch: {
// inputFontFamily fontFamily inputFontFamily 500ms
inputFontFamily: lodash.debounce(value => {
getComponentSettings('customFontFamily').options.fontFamily = value
}, 500),
async mouseOnPeekIcon(value: boolean) {
if (!value) {
this.isPeeking = false
return
}
if (value) {
await delay(200)
}
if (this.mouseOnPeekIcon) {
this.isPeeking = true
}
},
},
methods: {
confirmResetOptions() {
if (confirm('确定将面板中的所有选项重置为默认值吗?')) {
this.resetOptions()
}
},
resetOptions() {
getComponentSettings('customFontFamily').options.fontFamily = fontFamilyDefaultValue
this.inputFontFamily = fontFamilyDefaultValue
const console = useScopedConsole('自定义字体')
console.log('已将字体设置面板中的所有选项重置为默认值')
},
},
})
</script>
<style lang="scss">
@import './extra-options-panel';
.custom-font-family-extra-options-panel {
@include extra-options-panel;
> .eop-content > .eop-c-section > .eop-c-s-input.input-font-family .be-text-area {
min-height: 160px;
}
}
</style>

View File

@ -1,24 +1,26 @@
import { mountVueComponent } from '@/core/utils'
let extraOptionsVm: Vue & {
popupOpen: boolean
triggerElement: HTMLElement
let panelVm: Vue & {
toggleDisplay: () => void
}
export const setTriggerElement = (element: HTMLElement) => {
extraOptionsVm.triggerElement = element
const getPanelLoadState = () => {
return Boolean(panelVm)
}
export const getExtraOptionsLoadState = async () => {
return Boolean(extraOptionsVm)
const mountPanel = async () => {
const panel = await import('./Panel.vue').then(m => m.default)
panelVm = mountVueComponent(panel)
document.body.insertAdjacentElement('beforeend', panelVm.$el)
}
export const loadExtraOptions = async () => {
const extraOptions = await import('./panel.vue').then(m => m.default)
extraOptionsVm = mountVueComponent(extraOptions)
document.body.insertAdjacentElement('beforeend', extraOptionsVm.$el)
export const loadPanel = async () => {
const isLoaded = getPanelLoadState()
if (!isLoaded) {
await mountPanel()
}
}
export const toggleExtraOptionsDisplay = async () => {
extraOptionsVm.popupOpen = !extraOptionsVm.popupOpen
export const togglePanelDisplay = async () => {
panelVm.toggleDisplay()
}

View File

@ -1,2 +0,0 @@
export const fontFamilyDefaultValue =
'Microsoft Yahei, Helvetica Neue, Helvetica, Arial, Hiragino Sans GB, Heiti SC, WenQuanYi Micro Hei'

View File

@ -0,0 +1,9 @@
使用组件提供的字体设置覆盖原版的主站字体,并使主站字体可被自定义。
当组件被启用后,几乎所有的元素会立即应用组件提供的字体设置。
选项说明:
- `禁用标题标点符号缩进`: 在新版视频页中,推荐视频栏中的视频标题,如果首个字符是特定的标点符号,则文本会缩入左侧。这个选项可以禁用这种样式
- `更多选项`:
- `自定义字体`: 设置自定义字体。写法请参考 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/CSS/font-family)、默认设置与设置说明。
- `覆盖选项`: 相当于一个白名单,使用了特殊字体的元素会被加入其中。默认情况下这些元素不会应用组件提供的字体设置,只有在启用对应的选项后才会应用。

View File

@ -1,6 +1,6 @@
import { defineComponentMetadata } from '@/components/define'
import { addComponentListener } from '@/core/settings'
import { fontFamilyDefaultValue } from './font-family-default-value'
import { fontFamilyDefaultValue, coverOptionsName, coverOptionsDefaultValue } from './data'
const docElement = document.documentElement
@ -12,11 +12,6 @@ const name = camelName
const displayName = '自定义字体'
const description = {
'zh-CN':
'使用组件提供的字体设置覆盖原版的主站字体,并使主站字体可被自定义。字体设置写法请参考 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/CSS/font-family) 、默认设置与设置说明',
}
const tags = [componentsTags.style, componentsTags.general]
const entry = () => {
@ -34,34 +29,25 @@ const entry = () => {
)
addComponentListener(
`${name}.disableQuotationMarkTextIndent`,
`${name}.disableTitlePunctuationTextIndent`,
(value: boolean) => {
docElement.setAttribute(
`${kebabName}--options--disable-quotation-mark-text-indent`,
`${kebabName}--options--disable-title-punctuation-text-indent`,
`${value}`,
)
},
true,
)
const onOptionNames = [
{ camel: 'onOrnament', kebab: 'on-ornament' },
{ camel: 'onFansMedal', kebab: 'on-fans-medal' },
{ camel: 'onDanmaku', kebab: 'on-danmaku' },
{ camel: 'onIconFont', kebab: 'on-icon-font' },
{ camel: 'onColumn', kebab: 'on-column' },
{ camel: 'onScore', kebab: 'on-score' },
]
onOptionNames.forEach(onOptionName => {
for (const coverOptionName of coverOptionsName) {
addComponentListener(
`${name}.${onOptionName.camel}`,
`${name}.${coverOptionName.camel}`,
(value: boolean) => {
docElement.setAttribute(`${kebabName}--options--${onOptionName.kebab}`, `${value}`)
docElement.setAttribute(`${kebabName}--options--${coverOptionName.kebab}`, `${value}`)
},
true,
)
})
}
}
const options = {
@ -70,47 +56,35 @@ const options = {
defaultValue: fontFamilyDefaultValue,
hidden: true,
},
disableQuotationMarkTextIndent: {
displayName: '禁用号缩进',
disableTitlePunctuationTextIndent: {
displayName: '禁用标题标点符号缩进',
defaultValue: true,
},
onOrnament: {
displayName: '覆盖装扮字体',
defaultValue: false,
},
onFansMedal: {
displayName: '覆盖粉丝勋章字体',
defaultValue: false,
},
onDanmaku: {
displayName: '覆盖弹幕字体',
defaultValue: false,
},
onIconFont: {
displayName: '覆盖图标字体',
defaultValue: false,
},
onColumn: {
displayName: '覆盖专栏自定义字体',
defaultValue: false,
},
onScore: {
displayName: '覆盖评分字体',
defaultValue: false,
},
}
const extraOptions = () => import('./extra-options/entry.vue').then(m => m.default)
const invokeCoverOptions = () => {
for (const coverOptionName of coverOptionsName) {
options[coverOptionName.camel] = {
displayName: coverOptionName.display,
defaultValue: coverOptionsDefaultValue[coverOptionName.camel],
hidden: true,
}
}
}
invokeCoverOptions()
const extraOptions = () => import('./extra-options/Entry.vue').then(m => m.default)
const instantStyles = [
{
name: `${name}--style--setFontFamily`,
name: `${kebabName}--style--set-font-family`, // style 标签 id
style: () => import('./set-font-family.scss'),
important: true,
},
{
name: `${name}--style--disableQuotationMarkTextIndent`,
style: () => import('./disable-quotation-mark-text-indent.scss'),
name: `${kebabName}--style--disable-title-punctuation-text-indent`,
style: () => import('./disable-title-punctuation-text-indent.scss'),
important: true,
},
]
@ -123,7 +97,6 @@ const author = {
export const component = defineComponentMetadata({
name,
displayName,
description,
tags,
entry,
options,

View File

@ -1,115 +1,117 @@
$name: 'custom-font-family';
$fontFamilyCss: var(--#{$name}--options--font-family) !important;
$font-family-css: var(--#{$name}--options--font-family) !important;
// 收集 CSS 路径为单一字符串
@function collectPaths($pathList) {
$stringPath: '';
@each $path in $pathList {
$i: index($pathList, $path);
@function collect-paths($path-list) {
$string-path: '';
@each $path in $path-list {
$i: index($path-list, $path);
@if $i!=1 {
$stringPath: '#{$stringPath}, ';
$string-path: '#{$string-path}, ';
}
$stringPath: '#{$stringPath}#{$path}';
$string-path: '#{$string-path}#{$path}';
}
@return $stringPath;
@return $string-path;
}
@function getOrnamentPath() {
$newVideo: '.reply-item .root-reply-container .content-warp .reply-decorate .user-sailing .user-sailing-text .sailing-text';
@function get-ornament-path() {
$new-video: '.reply-item .root-reply-container .content-warp .reply-decorate .user-sailing .user-sailing-text .sailing-text';
$dynamic: '.bili-dyn-ornament__type--3 span';
$dynamicCommentArea: '.bb-comment .sailing .sailing-info, .comment-bilibili-fold .sailing .sailing-info';
@return collectPaths(($newVideo, $dynamic, $dynamicCommentArea));
$dynamic-comment-area: '.bb-comment .sailing .sailing-info, .comment-bilibili-fold .sailing .sailing-info';
@return collect-paths(($new-video, $dynamic, $dynamic-comment-area));
}
@function getFansMedalPath() {
$newVideo: '.badge-level';
@return collectPaths(($newVideo));
@function get-fans-medal-path() {
$new-video: '.badge-level';
@return collect-paths(($new-video));
}
@function getDanmakuPath() {
@function get-danmaku-path() {
$main: '.bili-dm';
@return collectPaths(($main));
@return collect-paths(($main));
}
@function getIconFontPath() {
$mainA: '.iconfont';
$mainB: '.icon-font';
@function get-icon-font-path() {
$main-a: '.iconfont';
$main-b: '.icon-font';
$note: '.read-icon';
$noteEdit: '.bili-note-iconfont';
$note-edit: '.bili-note-iconfont';
$footer: '.bili-footer-font';
$bangumiInfo: '[class^="icon-"]';
$bangumi-info: '[class^="icon-"]';
$history: '.bilifont';
$message: '.bp-icon-font';
$creativeCenterA: '.bcc-iconfont';
$creativeCenterB: '.fontvt';
@return collectPaths(
$creative-center-a: '.bcc-iconfont';
$creative-center-b: '.fontvt';
$quick-favorite: 'span.quick-favorite.be-quick-favorite > i.quick-favorite-icon.icon'; // 组件-启用快速收藏的图标参见 #4566
@return collect-paths(
(
$mainA,
$mainB,
$main-a,
$main-b,
$note,
$noteEdit,
$note-edit,
$footer,
$bangumiInfo,
$bangumi-info,
$history,
$message,
$creativeCenterA,
$creativeCenterB
$creative-center-a,
$creative-center-b,
$quick-favorite
)
);
}
@function getColumnPath() {
$mainA: '.article-detail .article-container .article-container__content .article-content .read-article-holder';
$mainB: '.article-detail .article-container .article-container__content .article-content .read-article-holder *';
@return collectPaths(($mainA, $mainB));
@function get-column-path() {
$main-a: '.article-detail .article-container .article-container__content .article-content .read-article-holder';
$main-b: '.article-detail .article-container .article-container__content .article-content .read-article-holder *';
@return collect-paths(($main-a, $main-b));
}
@function getScorePath() {
@function get-score-path() {
$documentary: '.season-cover .score';
$big: '.season-cover .season-cover-score';
@return collectPaths(($documentary, $big));
@return collect-paths(($documentary, $big));
}
// 装扮在评论区动态等界面显示
$ornament: getOrnamentPath();
$ornament: get-ornament-path();
// 粉丝勋章在评论区界面显示
$fansMedal: getFansMedalPath();
$fans-medal: get-fans-medal-path();
// 弹幕在播放器里显示
$danmaku: getDanmakuPath();
$danmaku: get-danmaku-path();
// 图标字体
$iconFont: getIconFontPath();
$icon-font: get-icon-font-path();
// 专栏阅读页可能出现的部分自定义字体
$column: getColumnPath();
$column: get-column-path();
// 分数在部分剧集的右下角显示
$score: getScorePath();
$score: get-score-path();
// 收集所有路径
$discardPaths: collectPaths(($ornament, $fansMedal, $danmaku, $iconFont, $column, $score));
$discard-paths: collect-paths(($ornament, $fans-medal, $danmaku, $icon-font, $column, $score));
$onOptionNames: (
'on-ornament',
'on-fans-medal',
'on-danmaku',
'on-icon-font',
'on-column',
'on-score'
$cover-option-names: (
'cover-ornament',
'cover-fans-medal',
'cover-danmaku',
'cover-icon-font',
'cover-column',
'cover-score'
);
$onOptionPathLists: ($ornament, $fansMedal, $danmaku, $iconFont, $column, $score);
$onOptionGroups: zip($onOptionNames, $onOptionPathLists);
$cover-option-path-lists: ($ornament, $fans-medal, $danmaku, $icon-font, $column, $score);
$cover-option-groups: zip($cover-option-names, $cover-option-path-lists);
html[#{$name}--detect--is-input-empty='false'] {
:not(#{$discardPaths}) {
font-family: $fontFamilyCss;
:not(#{$discard-paths}) {
font-family: $font-family-css;
}
@each $onOptionName, $onOptionPathList in $onOptionGroups {
&[#{$name}--options--#{$onOptionName}='true'] :is(#{$onOptionPathList}) {
font-family: $fontFamilyCss;
@each $cover-option-name, $cover-option-path-list in $cover-option-groups {
&[#{$name}--options--#{$cover-option-name}='true'] :is(#{$cover-option-path-list}) {
font-family: $font-family-css;
}
}
}

View File

@ -121,7 +121,8 @@ body.fixed-navbar {
top: calc(var(--navbar-height) + 8px) !important;
}
}
.bili-feed4 .header-channel {
.bili-feed4 .header-channel,
.search-fixed-header {
display: none !important;
}
}

View File

@ -772,4 +772,26 @@
#{contains('live-info-content')} {
background: transparent !important;
}
}
#{contains('upower-pay-container')} {
.header {
@include background-color('0');
}
#{contains('content')} {
@include background('2');
#{contains('tab-group')} {
#{contains('active')} {
@include color('f');
}
}
#{contains('month-charge-grade')} {
background: #333;
}
#{contains('charge-grad-container')} {
.charge-grad-item {
background: linear-gradient(45deg, #333, #444);
}
}
}
}

View File

@ -48,6 +48,10 @@ const options = defineOptionsMetadata({
displayName: '禁用UP主弹幕',
defaultValue: true,
},
upSlogan: {
displayName: '禁用带货弹幕',
defaultValue: true,
},
vip: {
displayName: '禁用大会员弹幕',
defaultValue: true,

View File

@ -37,3 +37,14 @@ body.disable-vip-danmaku-style {
--textShadow: var(--danmaku-text-shadow);
}
}
body.disable-upSlogan-danmaku-style {
.bili-upslogan {
padding: 0 !important;
opacity: var(--opacity) !important;
font-weight: var(--fontWeight) !important;
background-color: transparent !important;
img {
display: none !important;
}
}
}

View File

@ -21,14 +21,15 @@ export const setupFeedImageExporter: ComponentEntry<Options> = async ({
text: '导出图片',
action: async () => {
const imageUrls: { url: string; extension: string }[] = []
dqa(card.element, '.main-content .img-content, .bili-album__preview__picture__img').forEach(
(img: HTMLImageElement | HTMLDivElement) => {
const urlData = retrieveImageUrl(img)
if (urlData && !imageUrls.some(({ url }) => url === urlData.url)) {
imageUrls.push(urlData)
}
},
)
dqa(
card.element,
'.main-content .img-content, .bili-album__preview__picture__img, .bili-album .preview__picture__img',
).forEach((img: HTMLImageElement | HTMLDivElement) => {
const urlData = retrieveImageUrl(img)
if (urlData && !imageUrls.some(({ url }) => url === urlData.url)) {
imageUrls.push(urlData)
}
})
if (imageUrls.length === 0) {
Toast.info('此条动态没有检测到任何图片.', '导出图片')
return

View File

@ -1,4 +1,5 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable yoda */
import { defineComponentMetadata } from '@/components/define'
import { CommentItem, CommentReplyItem } from '@/components/utils/comment-apis'
@ -109,14 +110,154 @@ const createSubReplyItem = function subReply(item, i) {
return dom
}
// 小黑屋专用带IP属地显示的评论创建
const blackroomCreateListCon = function listCon(e, n, t) {
const r = this._parentBlacklistDom(e, n, t)
const i = [
`<div class="con ${t === n ? 'no-border' : ''}">`,
`<div class="user">${this._identity(e.mid, e.assist, e.member.fans_detail)}`,
`<a data-usercard-mid="${e.mid}" href="//space.bilibili.com/${
e.mid
}" target="_blank" class="name ${this._createVipClass(
e.member.vip.vipType,
e.member.vip.vipStatus,
e.member.vip.themeType,
)}">${this._unhtmlFix(
e.member.uname,
)}</a><a class="level-link" href="//www.bilibili.com/blackboard/help.html#%E4%BC%9A%E5%91%98%E7%AD%89%E7%BA%A7%E7%9B%B8%E5%85%B3" target="_blank"><i class="level l${
e.member.level_info.current_level
}"></i></a>${this._createNameplate(e.member.nameplate)}${this._createUserSailing(
(e.member && e.member.user_sailing) || {},
)}</div>`,
this._createMsgContent(e),
'<div class="info">',
e.floor ? `<span class="floor">#${e.floor}</span>` : '',
this._createPlatformDom(e.content.plat),
`<span class="time">${this._formateTime(e.ctime)}</span>`,
e?.reply_control?.location
? `<span style="margin-left:-15px;">${e?.reply_control?.location || ''}</span>`
: '',
e.lottery_id
? ''
: `<span class="like ${1 === e.action ? 'liked' : ''}"><i></i><span>${
e.like || ''
}</span></span>`,
e.lottery_id ? '' : `<span class="hate ${2 === e.action ? 'hated' : ''}"><i></i></span>`,
e.lottery_id ? '' : this._createReplyBtn(e.rcount),
e.lottery_id && e.mid !== this.userStatus.mid
? ''
: `<div class="operation more-operation"><div class="spot"></div><div class="opera-list"><ul>${
this._canSetTop(e)
? `<li class="set-top">${e.isUpTop ? '取消置顶' : '设为置顶'}</li>`
: ''
}${this._canBlackList(e.mid) ? '<li class="blacklist">加入黑名单</li>' : ''}${
this._canReport(e.mid) ? '<li class="report">举报</li>' : ''
}${
this._canDel(e.mid) && !e.isTop ? `<li class="del" data-mid="${e.mid}">删除</li>` : ''
}</ul></div></div>`,
this._createLotteryContent(e.content),
this._createVoteContent(e.content),
this._createTags(e),
'</div>',
'<div class="reply-box">',
this._createSubReplyList(e.replies, e.rcount, !1, e.rpid, e.folder && e.folder.has_folded),
'</div>',
'<div class="paging-box">',
'</div>',
'</div>',
].join('')
return e.state === this.blacklistCode ? r : i
}
// 小黑屋专用带IP属地显示的回复创建
const blackroomCreateSubReplyItem = function subReply(e, n) {
const t = [
`<div class="reply-item reply-wrap" data-id="${e.rpid}" data-index="${n}">`,
`<a href="//space.bilibili.com/${e.mid}" data-usercard-mid="${e.mid}" target="_blank" class="reply-face">`,
`<img src="${this._trimHttpFix(
this._webpFix(e.member.avatar, {
w: 52,
h: 52,
}),
)}" alt="">`,
'</a>',
'<div class="reply-con">',
'<div class="user">',
`<a href="//space.bilibili.com/${e.mid}" target="_blank" data-usercard-mid="${
e.mid
}" class="name ${this._createVipClass(
e.member.vip.vipType,
e.member.vip.vipStatus,
e.member.vip.themeType,
)}">${this._unhtmlFix(e.member.uname)}</a>`,
`<a class="level-link" href="//www.bilibili.com/blackboard/help.html#%E4%BC%9A%E5%91%98%E7%AD%89%E7%BA%A7%E7%9B%B8%E5%85%B3" target="_blank"><i class="level l${e.member.level_info.current_level}"></i></a>`,
this._createSubMsgContent(e),
'</div>',
'</div>',
'<div class="info">',
`<span class="time">${this._formateTime(e.ctime)}</span>`,
e?.reply_control?.location
? `<span style="margin-left:-15px;">${e?.reply_control?.location || ''}</span>`
: '',
`<span class="like ${1 === e.action ? 'liked' : ''}"><i></i><span>${
e.like || ''
}</span></span>`,
`<span class="hate ${2 === e.action ? 'hated' : ''}"><i></i></span>`,
'<span class="reply btn-hover">回复</span>',
`<div class="operation btn-hover btn-hide-re"><div class="spot"></div><div class="opera-list"><ul>${
this._canBlackList(e.mid) ? '<li class="blacklist">加入黑名单</li>' : ''
}${this._canReport(e.mid) ? '<li class="report">举报</li>' : ''}${
this._canDel(e.mid) ? `<li class="del" data-mid="${e.mid}">删除</li>` : ''
}</ul></div></div>`,
'</div>',
'</div>',
].join('')
return t
}
const observer = new MutationObserver(mutations => {
mutations.forEach(() => {
// 监听旧版评论区全局变量
if (typeof unsafeWindow.bbComment !== 'undefined') {
// 评论创建函数替换
bbComment = unsafeWindow.bbComment
bbComment.prototype._createListCon = createListCon
bbComment.prototype._createSubReplyItem = createSubReplyItem
if (unsafeWindow.location.href.split('/')[3] === 'blackroom') {
// 小黑屋特判
// 三个谜之函数的polyfill不知为何无法直接调用会报错
bbComment.prototype._unhtmlFix = function unhtml(e, n) {
return e
? // eslint-disable-next-line @typescript-eslint/no-shadow
e.replace(n || /[&<">'](?:(amp|lt|quot|gt|#39|nbsp|#\\d+);)?/g, function (e, n) {
return n ? e : { '<': '', '&': '', '"': '', '>': '', "'": '' }[e]
})
: ''
}
bbComment.prototype._trimHttpFix = function trimHttp(e) {
return e ? e.replace(/^http:/, '') : ''
}
bbComment.prototype._webpFix = function webp(e, n) {
if (!e) {
return e
}
const t = e.match(/(.*\\.(jpg|jpeg|gif|png|bmp))(\\?.*)?/)
let r = -1 !== e.indexOf('/bfs/')
if (!t || 'gif' === t[2] || 'bmp' === t[2] || !r) {
return e
}
r = n.w
e = n.h
e = r && e ? `@${r}w_${e}h` : '@'
n = t[3] || ''
return this.isWebp ? `${t[1] + e}.webp${n}` : `${t[1] + e}.${t[2]}${n}`
}
bbComment.prototype._createListCon = blackroomCreateListCon
bbComment.prototype._createSubReplyItem = blackroomCreateSubReplyItem
} else {
// 其他情况旧版评论区替换
bbComment.prototype._createListCon = createListCon
bbComment.prototype._createSubReplyItem = createSubReplyItem
}
version = 1
observer.disconnect()
}

View File

@ -3,23 +3,30 @@ import { getComponentSettings } from '@/core/settings'
import { registerAndGetData } from '@/plugins/data'
import { Options } from '.'
import { KeyBindingAction, KeyBindingActionContext } from './bindings'
import { simulateClick } from '@/core/utils'
export const keyboardEventToPointer = (event: KeyboardEvent): PointerEventInit => {
return {
...lodash.pick(event, 'ctrlKey', 'shiftKey', 'altKey', 'metaKey'),
bubbles: true,
cancelable: true,
view: unsafeWindow,
}
}
export const clickElement = (target: string | HTMLElement, context: KeyBindingActionContext) => {
const { event } = context
const mouseEvent = new MouseEvent('click', {
...lodash.pick(event, 'ctrlKey', 'shiftKey', 'altKey', 'metaKey'),
})
const eventParams = keyboardEventToPointer(event)
if (typeof target === 'string') {
const targetElement = dq(target) as HTMLElement
if (!targetElement) {
return false
}
targetElement.dispatchEvent(mouseEvent)
simulateClick(targetElement, eventParams)
} else {
if (!target) {
return false
}
target.dispatchEvent(mouseEvent)
simulateClick(target, eventParams)
}
return true
}
@ -82,7 +89,8 @@ export const builtInActions: Record<string, KeyBindingAction> = {
volumeUp: {
displayName: '增加音量',
run: () => {
const volume = playerAgent.changeVolume(10)
const step = getComponentSettings<Options>('keymap').options.volumeStep
const volume = playerAgent.changeVolume(step)
if (lodash.isNil(volume)) {
return volume
}
@ -93,7 +101,8 @@ export const builtInActions: Record<string, KeyBindingAction> = {
volumeDown: {
displayName: '降低音量',
run: () => {
const volume = playerAgent.changeVolume(-10)
const step = getComponentSettings<Options>('keymap').options.volumeStep
const volume = playerAgent.changeVolume(-step)
if (lodash.isNil(volume)) {
return volume
}
@ -127,7 +136,7 @@ export const builtInActions: Record<string, KeyBindingAction> = {
coin: {
displayName: '投币',
run: useClickElement(
'.video-toolbar .coin, .tool-bar .coin-info, .video-toolbar-module .coin-box, .play-options-ul > li:nth-child(2), .video-toolbar-v1 .coin',
'.video-toolbar .coin, .tool-bar .coin-info, .video-toolbar-module .coin-box, .play-options-ul > li:nth-child(2), .video-toolbar-v1 .coin, .toolbar .coin',
),
},
favorite: {
@ -143,31 +152,32 @@ export const builtInActions: Record<string, KeyBindingAction> = {
like: {
displayName: '点赞',
run: (() => {
/** 长按`L`三连使用的记忆变量 */
/** 长按 `L` 三连使用的记忆变量 */
let likeClick = true
return (context: KeyBindingActionContext) => {
const { event } = context
const likeButton = dq(
'.video-toolbar .like, .tool-bar .like-info, .video-toolbar-v1 .like',
'.video-toolbar .like, .tool-bar .like-info, .video-toolbar-v1 .like, .toolbar .like',
) as HTMLSpanElement
if (!likeButton) {
return false
}
event.preventDefault()
const fireEvent = (name: string, args: Event) => {
const customEvent = new CustomEvent(name, args)
likeButton.dispatchEvent(customEvent)
const fireMouseEvent = (name: string, source: KeyboardEvent) => {
const eventParams: MouseEventInit = keyboardEventToPointer(source)
const mouseEvent = new MouseEvent(name, eventParams)
likeButton.dispatchEvent(mouseEvent)
}
likeClick = true
setTimeout(() => (likeClick = false), 200)
fireEvent('mousedown', event)
fireMouseEvent('mousedown', event)
document.body.addEventListener(
'keyup',
e => {
e.preventDefault()
fireEvent('mouseup', e)
fireMouseEvent('mouseup', e)
if (likeClick) {
fireEvent('click', e)
fireMouseEvent('click', e)
}
},
{ once: true },

View File

@ -9,11 +9,18 @@ import { addComponentListener } from '@/core/settings'
import { actions } from './actions'
import { KeyBinding, KeyBindingConfig, loadKeyBindings } from './bindings'
import { presetBase, presets } from './presets'
import { getNumberValidator } from '@/core/utils'
const options = defineOptionsMetadata({
longJumpSeconds: {
defaultValue: 85,
displayName: '长跳跃秒数',
validator: getNumberValidator(1),
},
volumeStep: {
defaultValue: 10,
displayName: '音量调整幅度',
validator: getNumberValidator(1, 100),
},
customKeyBindings: {
defaultValue: {} as Record<string, string>,

View File

@ -32,7 +32,8 @@ body.remove-game-match-module #reportFirst3,
.video-page-game-card-small,
.video-page-operator-card-small,
[data-be-promotion-mark],
body:not(.preserve-feed-goods) .dyn-goods {
body:not(.preserve-feed-goods) .dyn-goods,
.desktop-download-tip {
display: none !important;
}
.recommend-list .rec-list > :not(.video-page-card) + .video-page-card {
@ -83,28 +84,6 @@ body.dark .blocked-ads.new {
body:not(.preserve-feed-goods) .bili-dyn-content__orig__additional:has(.dyn-goods) {
margin: 0 !important;
}
.bili-video-card__wrap {
a[href*='cm.bilibili.com'] {
pointer-events: none !important;
cursor: default !important;
> * {
visibility: hidden !important;
}
& ~ .bili-video-card__info {
display: none !important;
}
body.promotion-show-placeholder &::before {
content: '🚫已屏蔽广告';
border-radius: 6px;
@include h-center();
justify-content: center;
position: absolute;
z-index: 2;
font-size: 16px;
width: 100%;
height: 100%;
background-color: #8882;
}
}
.bili-video-card__wrap:has(a[href*='cm.bilibili.com']) {
display: none !important;
}

View File

@ -0,0 +1,52 @@
<template>
<div class="download-cover-config download-video-config-section">
<div class="download-video-config-item">
<div class="download-video-config-title">封面:</div>
<VDropdown v-model="type" :items="items">
<template #item="{ item }">
{{ item }}
</template>
</VDropdown>
</div>
</div>
</template>
<script lang="ts">
import { getComponentSettings } from '@/core/settings'
import { UnknownOptions } from '@/components/component'
import { VDropdown } from '@/ui'
import { CoverDownloadType } from './types'
interface Options extends UnknownOptions {
CoverType: CoverDownloadType | '无'
}
const { options } = getComponentSettings<Options>('downloadVideo')
export default Vue.extend({
components: {
VDropdown,
},
data() {
return {
type: options.CoverType ?? '无',
items: ['无', 'jpg'],
}
},
computed: {
enabled() {
return this.type !== '无'
},
},
watch: {
type(newValue: CoverDownloadType) {
options.CoverType = newValue
},
},
})
</script>
<style lang="scss">
.download-cover-config.download-video-config-section {
.be-dropdown {
text-transform: uppercase;
}
}
</style>

View File

@ -9,12 +9,11 @@
</template>
<script lang="ts">
import { getVideoCoverUrlByAid } from '@/components/video/video-cover'
import { getJson } from '@/core/ajax'
import { videoChange } from '@/core/observer'
import { select } from '@/core/spin-query'
import { logError } from '@/core/utils/log'
import { showImage, DefaultWidget } from '@/ui'
import { VideoInfo } from '@/components/video/video-info'
export default Vue.extend({
components: {
@ -29,14 +28,7 @@ export default Vue.extend({
if (!document.URL.includes('live.bilibili.com')) {
videoChange(async () => {
const { aid } = unsafeWindow
const videoInfo = new VideoInfo(aid)
try {
await videoInfo.fetchInfo()
} catch (error) {
logError(error)
throw error
}
this.imageUrl = videoInfo.coverUrl.replace('http:', 'https:')
this.imageUrl = await getVideoCoverUrlByAid(aid)
})
} else {
const spaceElementSelector = '.header-info-ctnr .room-cover, .header-info-ctnr .avatar'

View File

@ -1,5 +1,10 @@
import { defineComponentMetadata } from '@/components/define'
import { getBlobByAid } from '@/components/video/video-cover'
import { PackageEntry } from '@/core/download'
import { videoAndBangumiUrls } from '@/core/utils/urls'
import { Toast } from '@/core/toast'
import { DownloadVideoAssets } from '../../video/download/types'
import { CoverDownloadType } from './types'
export const component = defineComponentMetadata({
name: 'viewCover',
@ -12,6 +17,49 @@ export const component = defineComponentMetadata({
entry: none,
reload: none,
unload: none,
plugin: {
displayName: '下载视频 - 下载封面支持',
setup: ({ addData }) => {
addData('downloadVideo.assets', async (assets: DownloadVideoAssets[]) => {
assets.push({
name: 'downloadCover',
displayName: '下载封面',
getAssets: async (
infos,
instance: {
type: CoverDownloadType
enabled: boolean
},
) => {
const { type, enabled } = instance
if (!enabled) {
return []
}
const toast = Toast.info('获取封面中...', '下载封面')
let downloadedItemCount = 0
const results = await Promise.allSettled(
infos.map(async info => {
const blob = await getBlobByAid(info.input.aid)
downloadedItemCount++
toast.message = `获取封面中... (${downloadedItemCount}/${infos.length})`
return {
name: `${info.input.title}.${type}`,
data: blob,
}
}),
)
const success = results.filter(
it => it.status === 'fulfilled',
) as PromiseFulfilledResult<PackageEntry>[]
const fail = results.filter(it => it.status === 'rejected') as PromiseRejectedResult[]
toast.message = `获取完成. 成功 ${success.length} 个, 失败 ${fail.length} 个.`
return success.map(it => it.value)
},
component: () => import('./Plugin.vue').then(m => m.default),
})
})
},
},
widget: {
component: () => import('./ViewCover.vue').then(m => m.default),
},

View File

@ -0,0 +1 @@
export type CoverDownloadType = 'jpg'

View File

@ -0,0 +1,3 @@
将稍后再看的链接重定向为普通播放网址.
- `重定向页面`: 对稍后再看列表页面里的链接重定向.
- `重定向顶栏`: 对 `自定义顶栏` 里的稍后再看链接重定向.

View File

@ -51,9 +51,6 @@ const entry: ComponentEntry = async ({ settings }) => {
export const component = defineComponentMetadata({
name: 'watchlaterRedirect',
displayName: '稍后再看重定向',
description: {
'zh-CN': '将稍后再看的链接重定向为普通播放网址.',
},
entry,
options: {
page: {

View File

@ -5,6 +5,7 @@ import { getFriendlyTitle } from '@/core/utils/title'
import { DanmakuConverterConfig, DanmakuConverter } from '../converter/danmaku-converter'
import { DanmakuType } from '../converter/danmaku-type'
import { XmlDanmaku } from '../converter/xml-danmaku'
import { playerAgent } from '@/components/video/player-agent'
export class JsonDanmaku {
// static SegmentSize = 6 * 60
@ -97,9 +98,6 @@ export const getUserDanmakuConfig = async () => {
const playerSettingsJson = localStorage.getItem('bilibili_player_settings')
if (playerSettingsJson) {
const playerSettings = JSON.parse(playerSettingsJson)
const getConfig = <T>(prop: string, defaultValue?: T): T =>
lodash.get(playerSettings, `setting_config.${prop}`, defaultValue)
// 屏蔽类型
config.blockTypes = (() => {
const result: (DanmakuType | 'color')[] = []
@ -111,7 +109,7 @@ export const getUserDanmakuConfig = async () => {
}
for (const [type, value] of Object.entries(blockValues)) {
if (lodash.get(playerSettings, `block.type_${type}`, true) === false) {
if (playerAgent.getPlayerConfig<boolean>(`block.type_${type}`, true) === false) {
result.push(...(value as (DanmakuType | 'color')[]))
}
}
@ -119,13 +117,17 @@ export const getUserDanmakuConfig = async () => {
})()
// 加粗
config.bold = getConfig('bold', false)
config.bold = playerAgent.getPlayerConfig('bold', false)
// 透明度
config.alpha = lodash.clamp(1 - parseFloat(getConfig('opacity', '0.4')), 0, 1)
config.alpha = lodash.clamp(
1 - parseFloat(playerAgent.getPlayerConfig('opacity', '0.4')),
0,
1,
)
// 分辨率
const resolutionFactor = 1.4 - 0.4 * getConfig('fontsize', 1)
const resolutionFactor = 1.4 - 0.4 * playerAgent.getPlayerConfig('fontsize', 1)
config.resolution = {
x: Math.round(1920 * resolutionFactor),
y: Math.round(1080 * resolutionFactor),
@ -133,7 +135,7 @@ export const getUserDanmakuConfig = async () => {
// 弹幕持续时长
config.duration = (() => {
const scrollDuration = 18 - 3 * getConfig('speedplus', 0)
const scrollDuration = 18 - 3 * playerAgent.getPlayerConfig('speedplus', 0)
return (danmaku: { type: number }) => {
switch (danmaku.type) {
case 4:
@ -146,23 +148,25 @@ export const getUserDanmakuConfig = async () => {
})()
// 底部间距
const bottomMargin = getConfig('danmakuArea', 0)
const bottomMargin = playerAgent.getPlayerConfig('danmakuArea', 0)
config.bottomMarginPercent = bottomMargin >= 100 ? 0 : bottomMargin / 100
// 无显示区域限制时要检查是否开启防挡字幕
if (config.bottomMarginPercent === 0 && getConfig('preventshade', false)) {
if (config.bottomMarginPercent === 0 && playerAgent.getPlayerConfig('preventshade', false)) {
config.bottomMarginPercent = 0.15
}
// 用户屏蔽词
const blockSettings = lodash.get(playerSettings, 'block.list', []) as {
/** 类型 */
t: 'keyword' | 'regexp' | 'user'
/** 内容 */
v: string
/** 是否开启 */
s: boolean
id: number
}[]
const blockSettings = playerAgent.getPlayerConfig<
{
/** 类型 */
t: 'keyword' | 'regexp' | 'user'
/** 内容 */
v: string
/** 是否开启 */
s: boolean
id: number
}[]
>('block.list', [])
config.blockFilter = danmaku => {
for (const b of blockSettings) {
if (!b.s) {

View File

@ -95,7 +95,7 @@ import { allQualities, VideoQuality } from '@/components/video/video-quality'
import { Toast } from '@/core/toast'
import { getFriendlyTitle } from '@/core/utils/title'
import { bangumiBatchInput } from './inputs/bangumi/batch'
import { videoBatchInput } from './inputs/video/batch'
import { videoBatchInput, videoSeasonBatchInput } from './inputs/video/batch'
import { videoSingleInput } from './inputs/video/input'
import { videoDashAvc, videoDashHevc, videoDashAv1, videoAudioDash } from './apis/dash'
import { videoFlv } from './apis/flv'
@ -113,6 +113,7 @@ import {
const [inputs] = registerAndGetData('downloadVideo.inputs', [
videoSingleInput,
videoBatchInput,
videoSeasonBatchInput,
bangumiBatchInput,
] as DownloadVideoInput[])
const [apis] = registerAndGetData('downloadVideo.apis', [

View File

@ -33,6 +33,9 @@
</div>
</div>
<div class="episodes-picker-items">
<div v-if="episodeItems.length === 0" class="episodes-picker-empty">
<VEmpty />
</div>
<div v-for="(item, index) of episodeItems" :key="item.key" class="episodes-picker-item">
<CheckBox
v-model="item.isChecked"
@ -54,7 +57,7 @@
</div>
</template>
<script lang="ts">
import { VButton, VIcon, CheckBox } from '@/ui'
import { VButton, VIcon, CheckBox, VEmpty } from '@/ui'
import { EpisodeItem } from './episode-item'
export default Vue.extend({
@ -62,6 +65,7 @@ export default Vue.extend({
VButton,
VIcon,
CheckBox,
VEmpty,
},
props: {
api: {
@ -171,5 +175,10 @@ export default Vue.extend({
opacity: 0.5;
}
}
&-empty {
@include h-center();
justify-content: center;
padding: 4px 0;
}
}
</style>

View File

@ -5,9 +5,8 @@
</div>
</template>
<script lang="ts">
import { getVideoCoverUrlByAid } from '@/components/video/video-cover'
import { videoChange } from '@/core/observer'
import { logError } from '@/core/utils/log'
import { VideoInfo } from '@/components/video/video-info'
export default Vue.extend({
data() {
@ -18,14 +17,7 @@ export default Vue.extend({
created() {
videoChange(async () => {
const { aid } = unsafeWindow
const videoInfo = new VideoInfo(aid)
try {
await videoInfo.fetchInfo()
} catch (error) {
logError(error)
throw error
}
this.imageUrl = videoInfo.coverUrl.replace('http:', 'https:')
this.imageUrl = await getVideoCoverUrlByAid(aid)
})
},
})

View File

@ -49,3 +49,52 @@ export const videoBatchInput: DownloadVideoInput = {
})
}),
}
export const videoSeasonBatchInput: DownloadVideoInput = {
name: 'video.seasonBatch',
displayName: '当前视频 (合集)',
match: videoUrls,
batch: true,
getInputs: async instance => instance?.checkedInputItems ?? [],
component: async () =>
createEpisodesPicker(async instance => {
const { aid, bvid } = unsafeWindow
const api = `https://api.bilibili.com/x/web-interface/wbi/view/detail?bvid=${bvid}&aid=${aid}`
const json = await getJsonWithCredentials(api)
if (json.code !== 0) {
logError(`获取视频合集列表失败, message = ${json.message}`)
return []
}
const sections: { episodes: any[] }[] = lodash.get(json, 'data.View.ugc_season.sections', [])
if (sections.length === 0) {
return []
}
const totalEpisodesLength = lodash.sumBy(sections, it => it.episodes.length)
return sections.flatMap((section, sectionIndex) => {
const { episodes = [] } = section
return episodes.map((episode, episodeIndex) => {
const currentIndex =
episodeIndex + lodash.sumBy(sections.slice(0, sectionIndex), it => it.episodes.length)
const key = episode.cid
const page = currentIndex + 1
const title = `P${page} ${episode.title}`
return {
key,
title,
isChecked: currentIndex < instance.maxCheckedItems,
durationText: formatDuration(episode.arc.duration),
inputItem: {
allowQualityDrop: true,
title: formatTitle(getGeneralSettings().batchFilenameFormat, false, {
title: episode.page.part,
cid: episode.cid,
n: formatNumber(page, totalEpisodesLength),
ep: episode.title,
}),
cid: episode.cid,
aid: episode.aid,
},
} as EpisodeItem
})
})
}),
}

View File

@ -51,5 +51,9 @@ export const component = defineComponentMetadata({
defaultValue: true,
displayName: '预告',
},
promotions: {
defaultValue: true,
displayName: '心动',
},
},
})

View File

@ -50,5 +50,12 @@ body {
display: none !important;
}
}
// https://www.bilibili.com/video/BV1Qe411d7LM/?t=221
&-promotions {
.bili-cmtime,
.bpx-player-cmtime {
display: none !important;
}
}
}
}

View File

@ -3,6 +3,17 @@ import { Toast } from '@/core/toast'
import { getFriendlyTitle } from '@/core/utils/title'
import { SubtitleConverterConfig } from '../subtitle-converter'
export interface SubtitleSettings {
bilingual: boolean
color: string
fade: boolean
fontsize: string
lan: string
opacity: number
position: string
scale: boolean
shadow: string
}
export interface SubtitleInfo {
id: number
id_str: string
@ -20,14 +31,10 @@ export const getSubtitleConfig = async (): Promise<[SubtitleConverterConfig, str
'../subtitle-converter'
)
const { playerAgent } = await import('@/components/video/player-agent')
const isBpxPlayer = dq('.bpx-player-video-wrap')
const playerSettingsText = isBpxPlayer
? localStorage.getItem('bpx_player_profile')
: localStorage.getItem('bilibili_player_settings')
if (!playerSettingsText) {
const subtitleSettings = playerAgent.getPlayerConfig<null, SubtitleSettings>('subtitle', null)
if (!subtitleSettings) {
return [SubtitleConverter.defaultConfig, '']
}
const subtitleSettings = JSON.parse(playerSettingsText).subtitle
const language = subtitleSettings.lan
const title = getFriendlyTitle(true)
const fontSizeMapping: { [key: number]: number } = {
@ -38,11 +45,8 @@ export const getSubtitleConfig = async (): Promise<[SubtitleConverterConfig, str
1.6: SubtitleSize.VeryLarge,
}
const size = fontSizeMapping[subtitleSettings.fontsize]
const color =
typeof subtitleSettings.color === 'number'
? subtitleSettings.color.toString(16)
: parseInt(subtitleSettings.color).toString(16)
const opacity = subtitleSettings.backgroundopacity ?? subtitleSettings.opacity
const color = parseInt(subtitleSettings.color).toString(16)
const { opacity } = subtitleSettings
const positions = {
bc: SubtitleLocation.BottomCenter,

View File

@ -0,0 +1 @@
移除动态里的充电专属动态, 装有 `动态过滤器` 时生效.

View File

@ -0,0 +1,24 @@
import type { PluginMetadata } from '@/plugins/plugin'
export const plugin: PluginMetadata = {
name: 'feedsFilter.pluginBlocks.chargeFeeds',
displayName: '动态过滤器 - 移除充电专属动态',
async setup() {
const { forEachFeedsCard, getVueData } = await import('@/components/feeds/api')
forEachFeedsCard({
added: card => {
const vueData = getVueData(card.element)
const blockedType: number | null = lodash.get(
vueData,
'data.modules.module_dynamic.major.blocked.blocked_type',
null,
)
const isBlockedByCharge = blockedType === 3
if (!isBlockedByCharge) {
return
}
card.element.classList.add('plugin-block')
},
})
},
}

View File

@ -0,0 +1 @@
提供一个 "空" 的输出选项, 只想获取下载视频的附带产物 (弹幕, 字幕等) 时可以使用此插件.

View File

@ -0,0 +1,17 @@
import { PluginMetadata } from '@/plugins/plugin'
import { DownloadVideoOutput } from '../../../../components/video/download/types'
export const plugin: PluginMetadata = {
name: 'downloadVideo.outputs.empty',
displayName: '下载视频 - 空输出',
setup: ({ addData }) => {
addData('downloadVideo.outputs', (outputs: DownloadVideoOutput[]) => {
outputs.push({
name: 'empty',
displayName: '空',
description: '不输出视频本身, 仅获取下载视频的附带产物.',
runAction: async () => lodash.noop(),
})
})
},
}

View File

@ -0,0 +1,47 @@
<template>
<div class="mpv-ex config">
<div class="config-item">
<span class="item-name">api_dev_key:</span>
<TextBox v-model="api_dev_key" @blur="onChange" />
</div>
<div class="config-item">
<span class="item-name">api_user_key:</span>
<TextBox v-model="api_user_key" @blur="onChange" />
</div>
</div>
</template>
<script lang="ts">
import { getComponentSettings } from '@/core/settings'
import { TextBox } from '@/ui'
const { options } = getComponentSettings<ConfigDataType>('downloadVideo')
export default Vue.extend<ConfigDataType>({
components: {
TextBox,
},
data() {
return {
...lodash.pick(options, ['api_dev_key', 'api_user_key']),
}
},
methods: {
onChange() {
Object.assign(options, lodash.pick(this, ['api_dev_key', 'api_user_key']))
},
},
})
</script>
<style lang="scss">
@import 'common';
.mpv-ex.config {
align-self: stretch;
@include v-stretch(12px);
> .config-item {
@include h-center(8px);
> .item-name {
}
}
}
</style>

View File

@ -0,0 +1,71 @@
import { monkey } from '@/core/ajax'
import { logError } from '@/core/utils/log'
import { DownloadVideoOutput } from '../../../../components/video/download/types'
const getPastebinUrl = async (str: string, config: ConfigDataType) => {
const sp = new URLSearchParams()
Object.entries({
api_dev_key: config.api_dev_key,
api_user_key: config.api_user_key, // ! 必须绑定一个账号,否则链接可能被吞
api_folder_key: 'm3u',
api_option: 'paste',
api_paste_code: str,
api_paste_private: '1',
api_paste_expire_date: '10M',
}).forEach(([key, value]) => sp.append(key, value))
const response = await monkey<string>({
url: 'https://pastebin.com/api/api_post.php',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
data: sp.toString(),
nocache: true,
responseType: 'text',
fetch: true,
})
if (/^Bad API request,/.test(response)) {
throw response
}
return response
}
export const MPV_Ex: DownloadVideoOutput<ConfigDataType> = {
name: 'mpv-ex',
displayName: 'MPV 输出支持加强版',
description:
'多文件时格式选择 flv使用前请先阅读 <a href="https://github.com/Asukaaaaaa/tricks/blob/main/Bilibili-Evolved%20mpv-ex%20%E6%8F%92%E4%BB%B6.md" target="blank">README</a>',
runAction: async (action, instance) => {
let finalURL: string
const mpv_protocol = 'mpv://--http-header-fields="referer:https://www.bilibili.com/"'
if (action.isSingleVideo) {
// 单文件直接生成 url
const frag = action.infos[0].fragments
if (frag.length === 1) {
finalURL = `${mpv_protocol} ${frag[0].url}`
} else if (frag.length === 2) {
finalURL = `${mpv_protocol} ${frag[0].url} --audio-file=${frag[1].url}`
}
} else {
// 多文件生成 .m3u 播放列表
const m3u = action.infos.reduce((acc, v) => {
return `${acc}#EXTINF:-1,${v.input.title}\n${v.fragments[0].url}\n` // ? 暂不支持外挂音轨,默认取第一个资源的链接
}, '#EXTM3U\n')
try {
const playlist = (await getPastebinUrl(m3u, instance)).replace(
/^(https:\/\/pastebin.com)\/(.*)/,
'$1/raw/$2?.m3u',
)
finalURL = `${mpv_protocol} ${playlist}`
} catch (error) {
logError(error)
return
}
}
console.log(finalURL)
window.open(finalURL)
},
component: () => import('./Config.vue').then(m => m.default),
}

View File

@ -0,0 +1,19 @@
import { PluginMetadata } from '@/plugins/plugin'
import { DownloadVideoOutput } from '../../../../components/video/download/types'
import { MPV_Ex } from './handler'
export const plugin: PluginMetadata = {
name: 'downloadVideo.outputs.mpv-ex',
displayName: '下载视频 - MPV 输出支持加强版',
author: {
name: 'asuaaa',
link: 'https://github.com/Asukaaaaaa',
},
description:
'为下载视频增加 MPV 输出,支持导出列表, 配置方式请参考 [README](https://github.com/Asukaaaaaa/tricks/blob/main/Bilibili-Evolved%20mpv-ex%20%E6%8F%92%E4%BB%B6.md)',
setup: ({ addData }) => {
addData('downloadVideo.outputs', (outputs: DownloadVideoOutput[]) => {
outputs.push(MPV_Ex)
})
},
}

View File

@ -0,0 +1,4 @@
type ConfigDataType = {
api_dev_key: string
api_user_key: string
}

View File

@ -0,0 +1,191 @@
/*
MIT License
Copyright (c) 2019 Jerome Wu
Modified 2023 WakelessSloth56
*/
/* eslint-disable @typescript-eslint/naming-convention */
const messageId = (() => {
let messageID = 0
return () => messageID++
})()
enum FFMessageType {
LOAD = 'LOAD',
EXEC = 'EXEC',
WRITE_FILE = 'WRITE_FILE',
READ_FILE = 'READ_FILE',
ERROR = 'ERROR',
}
export class FFmpeg {
#worker: Worker | null = null
#resolves: Callbacks = {}
#rejects: Callbacks = {}
public loaded = false
#registerHandlers = () => {
if (this.#worker) {
this.#worker.onmessage = ({ data: { id, type, data } }: FFMessageEventCallback) => {
switch (type) {
case FFMessageType.LOAD:
this.loaded = true
this.#resolves[id](data)
break
case FFMessageType.EXEC:
case FFMessageType.WRITE_FILE:
case FFMessageType.READ_FILE:
this.#resolves[id](data)
break
case FFMessageType.ERROR:
this.#rejects[id](data)
break
default:
// https://github.com/the1812/Bilibili-Evolved/pull/4521#discussion_r1402041877
break
}
delete this.#resolves[id]
delete this.#rejects[id]
}
}
}
#send = (
{ type, data }: Message,
trans: Transferable[] = [],
signal?: AbortSignal,
): Promise<CallbackData> => {
if (!this.#worker) {
return Promise.reject(new Error('FFmpeg is not loaded'))
}
return new Promise((resolve, reject) => {
const id = messageId()
this.#worker && this.#worker.postMessage({ id, type, data }, trans)
this.#resolves[id] = resolve
this.#rejects[id] = reject
signal?.addEventListener(
'abort',
() => {
reject(new DOMException(`Message # ${id} was aborted`, 'AbortError'))
},
{ once: true },
)
})
}
public load = (config: FFMessageLoadConfig, signal?: AbortSignal) => {
if (!this.#worker) {
this.#worker = new Worker(config.workerLoadURL, { type: 'classic' })
this.#registerHandlers()
}
return this.#send(
{
type: FFMessageType.LOAD,
data: config,
},
undefined,
signal,
) as Promise<boolean>
}
public exec = (args: string[], timeout = -1, signal?: AbortSignal) =>
this.#send(
{
type: FFMessageType.EXEC,
data: { args, timeout },
},
undefined,
signal,
) as unknown as Promise<number>
public terminate = () => {
const ids = Object.keys(this.#rejects)
for (const id of ids) {
this.#rejects[id](new Error('FFmpeg terminated'))
delete this.#rejects[id]
delete this.#resolves[id]
}
if (this.#worker) {
this.#worker.terminate()
this.#worker = null
this.loaded = false
}
}
public writeFile = (path: string, data: Uint8Array, signal?: AbortSignal) => {
const trans: Transferable[] = []
trans.push(data.buffer)
return this.#send(
{
type: FFMessageType.WRITE_FILE,
data: { path, data },
},
trans,
signal,
) as Promise<boolean>
}
public readFile = (path: string, signal?: AbortSignal) =>
this.#send(
{
type: FFMessageType.READ_FILE,
data: { path, encoding: 'binary' },
},
undefined,
signal,
) as Promise<Uint8Array>
}
// ========================================================================== //
interface FFMessageLoadConfig {
workerLoadURL: string
coreURL: string
wasmURL: string
}
interface FFMessageExecData {
args: string[]
timeout?: number
}
interface FFMessageWriteFileData {
path: string
data: Uint8Array
}
interface FFMessageReadFileData {
path: string
encoding: string
}
type FFMessageData =
| FFMessageLoadConfig
| FFMessageExecData
| FFMessageWriteFileData
| FFMessageReadFileData
interface Message {
type: string
data?: FFMessageData
}
type CallbackData = Uint8Array | string | boolean | Error | undefined
interface Callbacks {
[id: number | string]: (data: CallbackData) => void
}
interface FFMessageEventCallback {
data: {
id: number
type: string
data: CallbackData
}
}

View File

@ -0,0 +1,49 @@
import { DownloadPackage } from '@/core/download'
import { meta } from '@/core/meta'
import { Toast } from '@/core/toast'
import { FFmpeg } from './ffmpeg'
import { httpGet, toBlobUrl, toastProgress } from './utils'
const ffmpeg = new FFmpeg()
async function load(toast: Toast) {
await ffmpeg.load({
workerLoadURL: await toBlobUrl(
meta.compilationInfo.altCdn.library.ffmpeg.worker,
'text/javascript',
toastProgress(toast, '正在加载 FFmpeg Worker'),
),
coreURL: await toBlobUrl(
meta.compilationInfo.altCdn.library.ffmpeg.core,
'text/javascript',
toastProgress(toast, '正在加载 FFmpeg Core'),
),
wasmURL: await toBlobUrl(
meta.compilationInfo.altCdn.library.ffmpeg.wasm,
'application/wasm',
toastProgress(toast, '正在加载 FFmpeg WASM'),
),
})
}
export async function run(name: string, videoUrl: string, audioUrl: string, toast: Toast) {
if (!ffmpeg.loaded) {
await load(toast)
}
ffmpeg.writeFile('video', await httpGet(videoUrl, toastProgress(toast, '正在下载视频流')))
ffmpeg.writeFile('audio', await httpGet(audioUrl, toastProgress(toast, '正在下载音频流')))
toast.message = '混流中……'
await ffmpeg.exec(['-i', 'video', '-i', 'audio', '-c:v', 'copy', '-c:a', 'copy', 'output.mp4'])
const output = await ffmpeg.readFile('output.mp4')
const outputBlob = new Blob([output], {
type: 'video/mp4',
})
toast.message = '完成!'
toast.duration = 1500
await DownloadPackage.single(name, outputBlob)
}

View File

@ -0,0 +1,40 @@
import { Toast } from '@/core/toast'
import { PluginMetadata } from '@/plugins/plugin'
import { DownloadVideoOutput } from '../../../../components/video/download/types'
import { run } from './handler'
const title = 'WASM 混流输出'
const desc = '使用 WASM 在浏览器中下载并合并音视频'
export const plugin: PluginMetadata = {
name: 'downloadVideo.outputs.wasm',
displayName: `下载视频 - ${title}`,
description: desc,
author: {
name: 'WakelessSloth56',
link: 'https://github.com/WakelessSloth56',
},
setup: ({ addData }) => {
addData('downloadVideo.outputs', (outputs: DownloadVideoOutput[]) => {
outputs.push({
name: 'wasm',
displayName: 'WASM',
description: `${desc},运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
runAction: async action => {
const fragments = action.infos.flatMap(it => it.titledFragments)
if (fragments.length !== 2) {
Toast.error('仅支持 DASH 格式', title)
} else {
const toast = Toast.info('正在加载', title)
try {
await run(fragments[0].title, fragments[0].url, fragments[1].url, toast)
} catch (error) {
toast.close()
Toast.error(String(error), title)
}
}
},
})
})
},
}

View File

@ -0,0 +1,54 @@
import { Toast } from '@/core/toast'
import { formatFileSize, formatPercent } from '@/core/utils/formatters'
type OnProgress = (received: number, length: number) => void
export function toastProgress(toast: Toast, message: string): OnProgress {
return (r, l) => {
toast.message = `${message}: ${formatFileSize(r)}${
l > 0 ? ` / ${formatFileSize(l)} @ ${formatPercent(r / l)}` : ''
}`
}
}
export async function httpGet(url: string, onprogress: OnProgress) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
// https://github.com/the1812/Bilibili-Evolved/pull/4521#discussion_r1402127375
const length = response.headers.get('Content-Encoding')
? -1
: parseInt(response.headers.get('Content-Length'))
let received = 0
const chunks = []
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
chunks.push(value)
received += value.length
onprogress(received, length)
}
const chunksAll = new Uint8Array(received)
let position = 0
for (const chunk of chunks) {
chunksAll.set(chunk, position)
position += chunk.length
}
return chunksAll
}
export async function toBlobUrl(url: string, mimeType: string, onprogress: OnProgress) {
const buffer = await httpGet(url, onprogress)
const blob = new Blob([buffer], { type: mimeType })
return URL.createObjectURL(blob)
}

View File

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

View File

@ -16,6 +16,12 @@ export const compatibilityPatch = () => {
'iframe',
isIframe() && transparentFrames.some(matchUrlPattern),
)
const { allVideoUrls } = await import('@/core/utils/urls')
if (allVideoUrls.some(url => matchUrlPattern(url))) {
const { playerPolyfill } = await import('@/components/video/player-adaptor')
playerPolyfill()
}
})
fullyLoaded(() => {
select('meta[name=spm_prefix]').then(spm => {

View File

@ -21,6 +21,7 @@ import * as videoInfo from './video/video-info'
import * as videoQuality from './video/video-quality'
import * as videoContextMenu from './video/video-context-menu'
import * as videoControlBar from './video/video-control-bar'
import * as videoCover from './video/video-cover'
import * as watchlater from './video/watchlater'
import * as liveControlBar from './live/live-control-bar'
import * as liveSocket from './live/live-socket'
@ -61,6 +62,7 @@ export const componentApis = {
videoQuality,
videoContextMenu,
videoControlBar,
videoCover,
watchlater,
xmlUtils,
},

View File

@ -13,7 +13,8 @@ import {
UpdateCheckItem,
CheckSingleTypeUpdateConfig,
} from './utils'
import { Options } from '.'
import type { Options } from '.'
import { isDataSaveMode } from '@/core/utils'
export const checkUpdate = async (config: CheckUpdateConfig) => {
const {
@ -23,9 +24,12 @@ export const checkUpdate = async (config: CheckUpdateConfig) => {
force = false,
maxCount = Infinity,
} = config
if (isDataSaveMode()) {
return '当前为流量计费网络, 跳过更新检查.'
}
const now = Number(new Date())
const { devMode } = getGeneralSettings()
const { options } = getComponentSettings(name)
const { options } = getComponentSettings<Options>(name)
// Remove uninstalled items
Object.keys(items)
.filter(it => !existPredicate(it))

View File

@ -166,7 +166,7 @@ export default Vue.extend({
if (!matchUrlPattern(/^https?:\/\/search\.bilibili\.com/)) {
return
}
select('#search-keyword').then((input: HTMLInputElement) => {
select('#search-keyword, .search-input-el').then((input: HTMLInputElement) => {
if (!input) {
return
}

View File

@ -24,6 +24,10 @@ export const component = defineComponentMetadata({
const { monkey } = await import('@/core/ajax')
const { meta } = await import('@/core/meta')
const { Toast } = await import('@/core/toast')
const { isDataSaveMode } = await import('@/core/utils')
if (isDataSaveMode()) {
return
}
const now = Number(new Date())
const duration = now - options.lastUpdateCheck
if (duration < options.minimumDuration) {

View File

@ -3,6 +3,7 @@ import { select } from '@/core/spin-query'
import { playerReady, preventEvent } from '@/core/utils'
import { createPlayerModeChangeEvent, PlayerMode } from './events'
/** 播放器模式标记同步 */
const playerModePolyfill = async () => {
await playerReady()
const bpxContainer = (await select('.bpx-player-container')) as HTMLElement
@ -35,37 +36,6 @@ const playerModePolyfill = async () => {
}
})
}
const idPolyfill = async () => {
let pbp = await select(() => unsafeWindow.$pbp)
if (!pbp) {
console.warn('[bpx player polyfill] pbp not found')
return
}
const loadPbpData = () => {
const idData = {
aid: pbp.options.aid.toString(),
cid: pbp.options.cid.toString(),
bvid: pbp.options.bvid,
}
if (Object.values(idData).some(it => !it || parseInt(it) <= 0)) {
console.warn('[bpx player polyfill] invalid pbp data', pbp.options)
}
Object.assign(unsafeWindow, idData)
}
Object.defineProperty(unsafeWindow, '$pbp', {
get() {
return pbp
},
set(newValue) {
pbp = newValue
if (newValue === undefined) {
return
}
Promise.resolve().then(() => loadPbpData())
},
})
loadPbpData()
}
/** 3.x .
* , , 使.
*/
@ -76,12 +46,9 @@ const doubleClickPolyfill = async () => {
}
preventEvent(layer, 'dblclick')
}
/** 番剧 & 视频播放器 (BPX) 通用 polyfill */
export const bpxPlayerPolyfill = lodash.once(async () => {
// 番剧 + 3.x 播放器通用
playerModePolyfill()
doubleClickPolyfill()
if (!document.URL.startsWith('https://www.bilibili.com/bangumi/play/')) {
return
}
idPolyfill()
})

View File

@ -1,38 +1,51 @@
/* eslint-disable no-underscore-dangle */
import { allMutations } from '@/core/observer'
import { select } from '@/core/spin-query'
import { matchUrlPattern } from '@/core/utils'
import { bangumiUrls } from '@/core/utils/urls'
const isIdValid = (id: string) => {
if (id === '') {
return false
}
const idNumber = parseInt(id)
if (Number.isNaN(idNumber)) {
return true
}
return idNumber > 0
}
const idPolyfill = async () => {
const player = await select(() => unsafeWindow.player)
if (!player?.__getUserParams()) {
if (!player?.getManifest) {
return
}
const { useScopedConsole } = await import('@/core/utils/log')
const console = useScopedConsole('v4 player polyfill')
allMutations(() => {
const { input } = player.__getUserParams()
if (!input) {
console.warn('invalid getUserParams data')
const manifest = player.getManifest()
if (!manifest) {
console.warn('invalid getManifest data')
return
}
const idData = {
aid: input.aid.toString(),
cid: input.cid.toString(),
bvid: input.bvid,
aid: manifest.aid.toString(),
cid: manifest.cid.toString(),
bvid: manifest.bvid ?? '',
}
if (Object.values(idData).some(it => it === '' || parseInt(it) <= 0)) {
console.warn('invalid input data')
const isIdDataValid = (() => {
const isBangumi = bangumiUrls.some(url => matchUrlPattern(url))
if (isBangumi) {
return isIdValid(idData.aid) && isIdValid(idData.cid)
}
return Object.values(idData).every(it => isIdValid(it))
})()
if (!isIdDataValid) {
console.warn('invalid manifest data', idData)
}
Object.assign(unsafeWindow, idData)
})
}
export const v4PlayerPolyfill = lodash.once(() => {
if (bangumiUrls.some(url => matchUrlPattern(url))) {
return
}
if (unsafeWindow.aid || unsafeWindow.cid) {
return
}

View File

@ -0,0 +1,31 @@
import { PlayerAgent, selectorWrap } from './base'
import { bpxSelectors } from './bpx'
import { AgentType, PlayerQuery, ElementQuery } from './types'
export class BangumiPlayerAgent extends PlayerAgent {
type: AgentType = 'bangumi'
query = selectorWrap({
playerWrap: '#bilibili-player-wrap',
bilibiliPlayer: '#bilibili-player',
...bpxSelectors,
}) as PlayerQuery<ElementQuery>
constructor() {
super()
}
seek(time: number) {
const seekResult = super.seek(time)
if (seekResult === null) {
return seekResult
}
setTimeout(() => {
const toastText = dq('.bpx-player-toast-row .bpx-player-toast-item .bpx-player-toast-text')
if (toastText?.textContent?.startsWith('已为您定位至')) {
toastText.textContent = '已为您定位至00:00'
}
})
return seekResult
}
}

View File

@ -1,104 +0,0 @@
import { bpxPlayerPolyfill } from '../player-adaptor/bpx'
import { PlayerAgent, selectorWrap } from './base'
import { AgentType, PlayerQuery, ElementQuery } from './types'
export class BangumiPlayerAgent extends PlayerAgent {
type: AgentType = 'bangumi'
query = selectorWrap({
playerWrap: '.player-module',
bilibiliPlayer: '.bpx-player-container',
playerArea: '.bpx-player-primary-area',
video: {
element: '.bpx-player-video-wrap video',
wrap: '.bpx-player-video-area',
top: '.bpx-player-top-wrap',
state: '.bpx-player-state-wrap',
panel: '.bpx-player-ending-panel',
popup: '.bpx-player-dialog-wrap',
subtitle: '.bpx-player-subtitle-wrap',
basDanmaku: '.bpx-player-bas-dm-wrap',
advDanmaku: '.bpx-player-adv-dm-wrap',
danmaku: '.bpx-player-row-dm-wrap',
container: '.bpx-player-video-wrap',
},
control: {
element: '.squirtle-controller',
wrap: '.bpx-player-control-wrap',
mask: '.bpx-player-control-mask',
top: '.bpx-player-control-top',
progress: '.squirtle-progress-wrap',
bottom: '.squirtle-controller-wrap',
bottomLeft: '.squirtle-controller-wrap-left',
bottomCenter: '.squirtle-controller-wrap-center',
bottomRight: '.squirtle-controller-wrap-right',
buttons: {
start: '.squirtle-video-start',
next: '.squirtle-video-next',
time: '.squirtle-time-wrap',
quality: '.squirtle-video-quality',
pageList: '.squirtle-video-pagelist',
speed: '.squirtle-video-speed',
subtitle: '.squirtle-video-subtitle',
volume: '.squirtle-video-volume .squirtle-volume-icon',
settings: '.squirtle-video-setting',
pip: '.squirtle-video-pip',
widescreen: '.squirtle-video-widescreen',
webFullscreen: '.squirtle-video-pagefullscreen',
fullscreen: '.squirtle-video-fullscreen',
},
settings: {
wrap: '.squirtle-setting-wrap',
lightOff: '.squirtle-lightoff',
},
},
toastWrap: '.bpx-player-tooltip-area',
danmakuTipLayer: '.bpx-player-dialog-wrap',
danmakuSwitch: '.bpx-player-dm-switch input',
}) as PlayerQuery<ElementQuery>
constructor() {
super()
bpxPlayerPolyfill()
}
isMute() {
const icon = this.query.control.buttons.volume.sync() as HTMLElement
return icon?.classList.contains('squirtle-volume-mute-state') ?? false
}
changeVolume(change: number) {
const video = this.query.video.element.sync() as HTMLVideoElement
if (!video) {
return null
}
video.volume = lodash.clamp(video.volume + change / 100, 0, 1)
return Math.round(video.volume * 100)
}
seek(time: number) {
const video = this.query.video.element.sync() as HTMLVideoElement
if (!video) {
return null
}
video.play()
setTimeout(() => {
video.currentTime = lodash.clamp(time, 0, video.duration)
const toastText = dq('.bpx-player-toast-row .bpx-player-toast-item .bpx-player-toast-text')
if (toastText?.textContent?.startsWith('已为您定位至')) {
toastText.textContent = '已为您定位至00:00'
}
})
return video.currentTime
}
changeTime(change: number) {
const video = this.query.video.element.sync() as HTMLVideoElement
if (!video) {
return null
}
video.currentTime = lodash.clamp(video.currentTime + change, 0, video.duration)
return video.currentTime
}
async toggleLight(on: boolean) {
const checkbox = this.query.control.settings.lightOff.sync()
// 开灯状态 && 关灯 -> 关灯
!checkbox.classList.contains('active') && !on && checkbox.click()
// 关灯状态 && 开灯 -> 开灯
checkbox.classList.contains('active') && on && checkbox.click()
}
}

View File

@ -1,3 +1,4 @@
/* eslint-disable class-methods-use-this */
import { select } from '@/core/spin-query'
import { raiseEvent } from '@/core/utils'
import {
@ -29,9 +30,23 @@ export const click = (target: ElementQuery) => {
button?.click()
return button
}
export abstract class PlayerAgent {
export enum PlayerAgentEventTypes {
Play = 'play',
}
export abstract class PlayerAgent
extends EventTarget
implements EnumEventTarget<`${PlayerAgentEventTypes}`>
{
isBpxPlayer = true
abstract type: AgentType
abstract query: PlayerQuery<ElementQuery>
constructor() {
super()
}
provideCustomQuery<CustomQueryType extends CustomQuery<string>>(
config: CustomQueryProvider<CustomQueryType>,
) {
@ -71,32 +86,145 @@ export abstract class PlayerAgent {
/** true 开灯false 关灯 */
async toggleLight(on?: boolean) {
const checkbox = (await this.query.control.settings.lightOff()) as HTMLInputElement
if (!this.nativeApi) {
return null
}
const isCurrentLightOff = this.nativeApi.getLightOff()
// 无指定参数, 直接 toggle
if (on === undefined) {
checkbox.click()
return
this.nativeApi.setLightOff(!isCurrentLightOff)
return !isCurrentLightOff
}
// 关灯状态 && 要开灯 -> 开灯
checkbox.checked && on && checkbox.click()
// 开灯状态 && 要关灯 -> 关灯
!checkbox.checked && !on && checkbox.click()
if (on && isCurrentLightOff) {
this.nativeApi.setLightOff(true)
return true
}
if (!on && !isCurrentLightOff) {
this.nativeApi.setLightOff(false)
return false
}
return null
}
// eslint-disable-next-line class-methods-use-this
getPlayerConfig(target: string) {
return lodash.get(JSON.parse(localStorage.getItem('bilibili_player_settings')), target, false)
getPlayerConfig<DefaultValueType = unknown, ValueType = DefaultValueType>(
target: string,
defaultValue?: DefaultValueType,
): ValueType | DefaultValueType {
const storageKey = this.isBpxPlayer ? 'bpx_player_profile' : 'bilibili_player_settings'
return lodash.get(JSON.parse(localStorage.getItem(storageKey)), target, defaultValue)
}
isAutoPlay() {
return this.getPlayerConfig('video_status.autoplay')
}
abstract isMute(): boolean
// https://github.com/the1812/Bilibili-Evolved/discussions/4341
get nativeApi() {
return unsafeWindow.player || unsafeWindow.playerRaw
}
get nanoApi() {
return unsafeWindow.nano
}
private eventHandlerMap = new Map<
EventListenerOrEventListenerObject,
{
handler: () => void
options: boolean | AddEventListenerOptions
}
>()
addEventListener(
type: `${PlayerAgentEventTypes}`,
callback: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, callback, options)
switch (type) {
default: {
break
}
case PlayerAgentEventTypes.Play: {
const handler = () => {
this.dispatchEvent(new Event(PlayerAgentEventTypes.Play))
}
if (typeof options === 'object' && options.once) {
this.nativeApi.once(this.nanoApi.EventType.Player_Initialized, handler)
} else {
this.nativeApi.on(this.nanoApi.EventType.Player_Initialized, handler)
}
this.eventHandlerMap.set(callback, {
handler,
options,
})
}
}
}
removeEventListener(
type: `${PlayerAgentEventTypes}`,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void {
super.removeEventListener(type, callback, options)
switch (type) {
default: {
break
}
case PlayerAgentEventTypes.Play: {
const handlerData = this.eventHandlerMap.get(callback)
if (!handlerData || lodash.isEqual(options, handlerData.options)) {
break
}
this.nativeApi.off(this.nanoApi.EventType.Player_Initialized, handlerData.handler)
}
}
}
/** 获取是否静音 */
isMute() {
if (!this.nativeApi) {
return null
}
if (this.nativeApi.isMuted) {
return this.nativeApi.isMuted()
}
return this.nativeApi.isMute()
}
/** 更改音量 (%) */
abstract changeVolume(change: number): number
changeVolume(change: number) {
if (!this.nativeApi) {
return null
}
if (this.nativeApi.getVolume) {
const current = this.nativeApi.getVolume()
this.nativeApi.setVolume(current + change / 100)
return Math.round(this.nativeApi.getVolume() * 100)
}
const current = this.nativeApi.volume()
this.nativeApi.volume(current + change / 100)
return Math.round(this.nativeApi.volume() * 100)
}
/** 跳转到指定时间 */
abstract seek(time: number): number
seek(time: number) {
if (!this.nativeApi) {
return null
}
this.nativeApi.seek(time)
return this.nativeApi.getCurrentTime() as number
}
/** 更改时间 */
abstract changeTime(change: number): number
changeTime(change: number) {
if (!this.nativeApi) {
return null
}
const video = this.query.video.element.sync() as HTMLVideoElement
if (!video) {
return null
}
this.nativeApi.seek(video.currentTime + change, video.paused)
return this.nativeApi.getCurrentTime()
}
}

View File

@ -0,0 +1,51 @@
// spell-checker: disable
export const bpxSelectors = {
playerArea: '.bpx-player-primary-area',
video: {
element: '.bpx-player-video-wrap video',
wrap: '.bpx-player-video-area',
top: '.bpx-player-top-wrap',
state: '.bpx-player-state-wrap',
panel: '.bpx-player-ending-panel',
popup: '.bpx-player-dialog-wrap',
subtitle: '.bpx-player-subtitle-wrap',
basDanmaku: '.bpx-player-bas-dm-wrap',
advDanmaku: '.bpx-player-adv-dm-wrap',
danmaku: '.bpx-player-row-dm-wrap',
container: '.bpx-player-video-perch',
},
control: {
element: '.bpx-player-control-entity',
wrap: '.bpx-player-control-wrap',
mask: '.bpx-player-control-mask',
top: '.bpx-player-control-top',
progress: '.bpx-player-progress',
bottom: '.bpx-player-control-bottom',
bottomLeft: '.bpx-player-control-bottom-left',
bottomCenter: '.bpx-player-control-bottom-center',
bottomRight: '.bpx-player-control-bottom-right',
buttons: {
start: '.bpx-player-ctrl-play',
next: '.bpx-player-ctrl-btn-next',
time: '.bpx-player-ctrl-time',
quality: '.bpx-player-ctrl-quality',
pageList: '.bpx-player-ctrl-eplist',
speed: '.bpx-player-ctrl-playbackrate',
subtitle: '.bpx-player-ctrl-subtitle > div > span',
volume: '.bpx-player-ctrl-volume-icon',
settings: '.bpx-player-ctrl-setting',
pip: '.bpx-player-ctrl-pip',
widescreen: '.bpx-player-ctrl-wide',
webFullscreen: '.bpx-player-ctrl-web',
fullscreen: '.bpx-player-ctrl-full',
},
settings: {
wrap: '.bpx-player-ctrl-setting-box',
lightOff: '.bpx-player-ctrl-setting-lightoff .bui-checkbox-input',
},
},
toastWrap: '.bpx-player-dialog-wrap',
danmakuTipLayer: '.bpx-player-dm-tip',
danmakuSwitch: '.bpx-player-dm-switch input',
}
// spell-checker: enable

View File

@ -1,10 +1,10 @@
import { bangumiUrls, matchCurrentPage } from '@/core/utils/urls'
import { BangumiPlayerAgent } from './bangumi'
import { VideoPlayerMixedAgent } from './video-player-mixed'
import { BangumiPlayerAgent } from './bangumi-player'
import { VideoPlayerBpxAgent } from './video-player'
export const playerAgent = (() => {
if (matchCurrentPage(bangumiUrls)) {
return new BangumiPlayerAgent()
}
return new VideoPlayerMixedAgent()
return new VideoPlayerBpxAgent()
})()

View File

@ -1,83 +0,0 @@
import { v3PlayerPolyfill } from '../player-adaptor/v3'
import { selectorWrap } from './base'
import { PlayerQuery, ElementQuery } from './types'
import { VideoPlayerV2Agent } from './video-player-v2'
export class VideoPlayerMixedAgent extends VideoPlayerV2Agent {
query = selectorWrap({
playerWrap: '.player-wrap',
bilibiliPlayer: '.bilibili-player, #bilibili-player',
playerArea: '.bilibili-player-area, .bpx-player-primary-area',
video: {
element: '.bilibili-player-video video, .bpx-player-video-wrap video',
wrap: '.bilibili-player-video-wrap, .bpx-player-video-area',
top: '.bilibili-player-video-top, .bpx-player-top-wrap',
state: '.bilibili-player-video-state, .bpx-player-state-wrap',
panel: '.bilibili-player-video-panel, .bpx-player-ending-panel',
popup: '.bilibili-player-video-popup, .bpx-player-dialog-wrap',
subtitle: '.bilibili-player-video-subtitle, .bpx-player-subtitle-wrap',
basDanmaku: '.bilibili-player-video-bas-danmaku, .bpx-player-bas-dm-wrap',
advDanmaku: '.bilibili-player-video-adv-danmaku, .bpx-player-adv-dm-wrap',
danmaku: '.bilibili-player-video-danmaku, .bpx-player-row-dm-wrap',
container: '.bilibili-player-video, .bpx-player-video-perch',
},
control: {
element: '.bilibili-player-control, .bpx-player-control-entity',
wrap: '.bilibili-player-control-wrap, .bpx-player-control-wrap',
mask: '.bilibili-player-control-mask, .bpx-player-control-mask',
top: '.bilibili-player-control-top, .bpx-player-control-top',
progress: '.bilibili-player-video-progress, .bpx-player-progress',
bottom: '.bilibili-player-control-bottom, .bpx-player-control-bottom',
bottomLeft: '.bilibili-player-control-bottom-left, ,.bpx-player-control-bottom-left',
bottomCenter: '.bilibili-player-control-bottom-center, .bpx-player-control-bottom-center',
bottomRight: '.bilibili-player-control-bottom-right, .bpx-player-control-bottom-right',
buttons: {
start: '.bilibili-player-video-btn-start, .bpx-player-ctrl-play',
next: '.bilibili-player-video-btn-next, .bpx-player-ctrl-btn-next',
time: '.bilibili-player-video-time, .bpx-player-ctrl-time',
quality: '.bilibili-player-btn-quality, .bpx-player-ctrl-quality',
pageList: '.bilibili-player-video-btn-pagelist, .bpx-player-ctrl-eplist',
speed: '.bilibili-player-video-btn-speed, .bpx-player-ctrl-playbackrate',
subtitle: '.bilibili-player-video-btn-subtitle, .bpx-player-ctrl-subtitle > div > span',
volume:
'.bilibili-player-video-btn-volume .bilibili-player-iconfont-volume, .bpx-player-ctrl-volume .bpx-player-ctrl-volume-icon',
settings: '.bilibili-player-video-btn-setting, .bpx-player-ctrl-setting',
pip: '.bilibili-player-video-btn-pip, .bpx-player-ctrl-pip',
widescreen: '.bilibili-player-video-btn-widescreen, .bpx-player-ctrl-wide',
webFullscreen: '.bilibili-player-video-web-fullscreen, .bpx-player-ctrl-web',
fullscreen: '.bilibili-player-video-btn-fullscreen, .bpx-player-ctrl-full',
},
settings: {
wrap: '.bilibili-player-video-btn-setting-wrap, .bpx-player-ctrl-setting-box',
lightOff:
'.bilibili-player-video-btn-setting-right-others-content-lightoff .bui-checkbox-input, .bpx-player-ctrl-setting-lightoff .bui-checkbox-input',
},
},
toastWrap: '.bilibili-player-video-toast-wrp, .bpx-player-dialog-wrap',
danmakuTipLayer: '.bilibili-player-dm-tip-wrap, .bpx-player-dm-tip',
danmakuSwitch: '.bilibili-player-video-danmaku-switch input, .bpx-player-dm-switch input',
}) as PlayerQuery<ElementQuery>
constructor() {
super()
this.checkBwpVideo()
v3PlayerPolyfill()
}
seek(time: number) {
if (!this.nativeApi) {
return null
}
this.nativeApi.play()
setTimeout(() => {
this.nativeApi.seek(time)
const toastText = dq(
'.bilibili-player-video-toast-bottom .bilibili-player-video-toast-item:first-child .bilibili-player-video-toast-item-text span:nth-child(2)',
)
if (toastText) {
toastText.textContent = ' 00:00'
}
})
return this.nativeApi.getCurrentTime()
}
}

View File

@ -3,11 +3,9 @@ import { isBwpVideo } from '@/core/utils'
import { PlayerAgent, selectorWrap } from './base'
import { AgentType, PlayerQuery, ElementQuery } from './types'
/** @deprecated Use `VideoPlayerBpxAgent` instead */
export class VideoPlayerV2Agent extends PlayerAgent {
// eslint-disable-next-line class-methods-use-this
get nativeApi() {
return unsafeWindow.player
}
isBpxPlayer = false
type: AgentType = 'video'
query = selectorWrap({
playerWrap: '.player-wrap',
@ -89,35 +87,12 @@ export class VideoPlayerV2Agent extends PlayerAgent {
})()
}
isMute() {
if (!this.nativeApi) {
return null
}
if (this.nativeApi.isMuted) {
return this.nativeApi.isMuted()
}
return this.nativeApi.isMute()
}
changeVolume(change: number) {
if (!this.nativeApi) {
return null
}
if (this.nativeApi.getVolume) {
const current = this.nativeApi.getVolume()
this.nativeApi.setVolume(current + change / 100)
return Math.round(this.nativeApi.getVolume() * 100)
}
const current = this.nativeApi.volume()
this.nativeApi.volume(current + change / 100)
return Math.round(this.nativeApi.volume() * 100)
}
seek(time: number) {
if (!this.nativeApi) {
return null
const seekResult = super.seek(time)
if (seekResult === null) {
return seekResult
}
this.nativeApi.play()
setTimeout(() => {
this.nativeApi.seek(time)
const toastText = dq(
'.bilibili-player-video-toast-bottom .bilibili-player-video-toast-item:first-child .bilibili-player-video-toast-item-text span:nth-child(2)',
)
@ -125,17 +100,6 @@ export class VideoPlayerV2Agent extends PlayerAgent {
toastText.textContent = ' 00:00'
}
})
return this.nativeApi.getCurrentTime()
}
changeTime(change: number) {
if (!this.nativeApi) {
return null
}
const video = this.query.video.element.sync() as HTMLVideoElement
if (!video) {
return null
}
this.nativeApi.seek(video.currentTime + change, video.paused)
return this.nativeApi.getCurrentTime()
return seekResult
}
}

View File

@ -0,0 +1,43 @@
import { isBwpVideo } from '@/core/utils'
import { PlayerAgent, selectorWrap } from './base'
import { PlayerQuery, ElementQuery, AgentType } from './types'
import { select } from '@/core/spin-query'
import { bpxSelectors } from './bpx'
export class VideoPlayerBpxAgent extends PlayerAgent {
type: AgentType = 'video'
query = selectorWrap({
playerWrap: '.player-wrap',
bilibiliPlayer: '#bilibili-player',
...bpxSelectors,
}) as PlayerQuery<ElementQuery>
constructor() {
super()
this.checkBwpVideo()
}
checkBwpVideo() {
const videoSelector = this.query.video.element.selector
const bwpSelector = '.bilibili-player-video bwp-video,.bpx-player-video-area bwp-video'
this.query.video.element = (() => {
const func = async () => {
if (await isBwpVideo()) {
return select<HTMLElement>(bwpSelector)
}
return select<HTMLElement>(videoSelector)
}
func.selector = videoSelector
func.sync = () => dq(videoSelector) as HTMLElement
isBwpVideo().then(bwp => {
if (!bwp) {
return
}
func.selector = bwpSelector
func.sync = () => dq(bwpSelector) as HTMLElement
})
return func
})()
}
}

View File

@ -0,0 +1,22 @@
import { logError } from '@/core/utils/log'
import { VideoInfo } from './video-info'
/** 根据 aid 获取视频封面地址 */
export const getVideoCoverUrlByAid = async (aid: string) => {
const videoInfo = new VideoInfo(aid)
try {
await videoInfo.fetchInfo()
} catch (error) {
logError(error)
throw error
}
return videoInfo.coverUrl.replace('http:', 'https:')
}
/** 根据 aid 下载视频封面为 Blob */
export const getBlobByAid = async (aid: string) => {
const url = await getVideoCoverUrlByAid(aid)
const response = await fetch(url)
const blob = await response.blob()
return blob
}

View File

@ -4,6 +4,7 @@ import { JSZipLibrary } from './runtime-library'
import { getGeneralSettings } from './settings'
import { formatFilename } from './utils/formatters'
import { useScopedConsole } from './utils/log'
import { getRandomId } from './utils'
/** 表示`DownloadPackage`中的一个文件 */
export interface PackageEntry {
@ -36,6 +37,7 @@ export class DownloadPackage {
}
/** 获取打包后的Blob数据 */
async blob(): Promise<Blob | null> {
const console = useScopedConsole('文件打包')
if (this.entries.length === 0) {
return null
}
@ -45,7 +47,20 @@ export class DownloadPackage {
}
const JSZip = await JSZipLibrary
const zip = new JSZip()
const fileExists = (name: string) => zip.filter((_, file) => file.name === name).length > 0
this.entries.forEach(({ name, data, options }) => {
if (fileExists(name)) {
let tempName = name
while (fileExists(tempName)) {
const extensionIndex = name.lastIndexOf('.')
tempName = `${name.substring(0, extensionIndex)}.${getRandomId(8)}${name.substring(
extensionIndex,
)}`
}
console.warn(`文件名 "${name}" 和已有文件冲突, 已临时更换为 "${tempName}"`)
zip.file(tempName, data, options)
return
}
zip.file(name, data, options)
})
return zip.generateAsync({ type: 'blob' })

View File

@ -241,7 +241,6 @@ export const urlChange = (callback: (url: string) => void, config?: AddEventList
/** 等待 cid */
const selectCid = lodash.once(() =>
select(() => {
import('@/components/video/player-adaptor').then(({ playerPolyfill }) => playerPolyfill())
if (unsafeWindow.cid) {
return unsafeWindow.cid
}
@ -264,8 +263,6 @@ export const videoChange = async (
if (!matchCurrentPage(playerUrls)) {
return false
}
const { playerPolyfill } = await import('@/components/video/player-adaptor')
playerPolyfill()
const cid = await selectCid()
if (cid === null) {
return false

View File

@ -37,7 +37,7 @@ export const addImportantStyle = (text: string, name?: string) =>
export const removeStyle = (...names: string[]) => {
names.forEach(name => {
const id = getDefaultStyleID(name)
dqa(`#${id}`).forEach(it => it.remove())
document.getElementById(id)?.remove()
})
}
/**

View File

@ -1,6 +1,6 @@
/** 请求有UA限制的网址时, 可以使用此内置UA (取自Firefox) */
/** 请求有 UA 限制的网址时, 可以使用此内置 UA (取自Firefox) */
export const UserAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:74.0) Gecko/20100101 Firefox/74.0'
/** 空白透明图像的URL */
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0'
/** 空白透明图像的 URL */
export const EmptyImageUrl =
'data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="0" height="0"></svg>'

View File

@ -205,8 +205,7 @@ export const isNotHtml = () => document.contentType !== 'text/html'
* @param eventName
*/
export const raiseEvent = (element: HTMLElement, eventName: string) => {
const event = document.createEvent('HTMLEvents')
event.initEvent(eventName, true, true)
const event = new Event(eventName)
element.dispatchEvent(event)
}
/** URL `srcset`, `@1x` `@4x`, `0.25x` `src`
@ -540,15 +539,21 @@ export const retrieveImageUrl = (element: HTMLElement) => {
if (!(element instanceof HTMLElement)) {
return null
}
let url: string
if (element.hasAttribute('data-src')) {
url = element.getAttribute('data-src')
} else if (element instanceof HTMLImageElement) {
url = element.src
} else if (dq(element, 'picture img')) {
const image = dq(element, 'picture img') as HTMLImageElement
url = image.src
} else {
const url = (() => {
if (element.hasAttribute('data-src')) {
return element.getAttribute('data-src')
}
if (element instanceof HTMLImageElement) {
return element.src
}
if (element instanceof HTMLPictureElement && dq(element, 'img')) {
const image = dq(element, 'img') as HTMLImageElement
return image.src
}
if (dq(element, 'picture img')) {
const image = dq(element, 'picture img') as HTMLImageElement
return image.src
}
const { backgroundImage } = element.style
if (!backgroundImage) {
return null
@ -557,8 +562,9 @@ export const retrieveImageUrl = (element: HTMLElement) => {
if (!match) {
return null
}
url = match[1]
}
return match[1]
})()
const thumbMatch = url.match(/^(.+)(\..+?)(@.+)$/)
if (thumbMatch) {
return {
@ -672,3 +678,26 @@ export const todo = (...args: unknown[]): never => {
export const unreachable = (): never => {
throw new Error(`unreachable`)
}
/** 是否为流量计费网络 (不支持的浏览器仍按 false 算) */
export const isDataSaveMode = () => {
return navigator.connection?.saveData ?? false
}
/**
* ( `pointerdown`, `mousedown`, `pointerup`, `mouseup`, `click` )
* @param target
* @param eventParams
*/
export const simulateClick = (target: EventTarget, eventParams?: PointerEventInit) => {
const mouseDownEvent = new MouseEvent('mousedown', eventParams)
const mouseUpEvent = new MouseEvent('mouseup', eventParams)
const pointerDownEvent = new PointerEvent('pointerdown', eventParams)
const pointerUpEvent = new PointerEvent('pointerup', eventParams)
const clickEventEvent = new MouseEvent('click', eventParams)
target.dispatchEvent(pointerDownEvent)
target.dispatchEvent(mouseDownEvent)
target.dispatchEvent(pointerUpEvent)
target.dispatchEvent(mouseUpEvent)
target.dispatchEvent(clickEventEvent)
}

View File

@ -37,39 +37,64 @@ export const formatTitle = (
extraVariables: StringMap = {},
) => {
const now = new Date()
const getLegacyTitle = () => {
return (
document.title
.replace(
/第[0-9]*[零一二三四五六七八九十百千]*[集话]-[^-]+-[^-]+-[^-]+在线观看-bilibili-哔哩哔哩$/,
'',
)
.replace(/-[^-]+-[^-]+-[^-]+在线观看-bilibili-哔哩哔哩$/, '')
.replace(/-[^-]+-[^-]+在线观看-bilibili-哔哩哔哩$/, '')
.replace(/([^]+?)_.+?_bilibili_哔哩哔哩$/, '')
.replace(/_哔哩哔哩_bilibili$/, '')
.replace(/ - 哔哩哔哩$/, '')
// b站不再有干杯了吗...
.replace(/_哔哩哔哩 \(゜-゜\)つロ 干杯~-bilibili$/, '')
.replace(/(.*?) - (.*?) - 哔哩哔哩直播,二次元弹幕直播平台$/, '$1')
.trim()
)
}
const builtInVariables: StringMap = {
title: document.title
.replace(
/第[0-9]*[零一二三四五六七八九十百千]*[集话]-[^-]+-[^-]+-[^-]+在线观看-bilibili-哔哩哔哩$/,
'',
)
.replace(/-[^-]+-[^-]+-[^-]+在线观看-bilibili-哔哩哔哩$/, '')
.replace(/-[^-]+-[^-]+在线观看-bilibili-哔哩哔哩$/, '')
.replace(/([^]+?)_.+?_bilibili_哔哩哔哩$/, '')
.replace(/_哔哩哔哩_bilibili$/, '')
.replace(/ - 哔哩哔哩$/, '')
// b站不再有干杯了吗...
.replace(/_哔哩哔哩 \(゜-゜\)つロ 干杯~-bilibili$/, '')
.replace(/(.*?) - (.*?) - 哔哩哔哩直播,二次元弹幕直播平台$/, '$1')
.trim(),
title: (() => {
const videoPageTitle = dq('.video-info-container .video-title')
if (videoPageTitle !== null) {
return videoPageTitle.getAttribute('title')
}
const bangumiPageTitle = dq('[class*="mediainfo_mediaTitle"]')
if (bangumiPageTitle !== null) {
return bangumiPageTitle.getAttribute('title')
}
const livePageTitle = dq('.header-info-ctnr .live-title .text')
if (livePageTitle !== null) {
return livePageTitle.getAttribute('title')
}
return getLegacyTitle()
})(),
ep: (() => {
if (!includesPageTitle) {
return undefined
}
const bangumiPage = dq('#eplist_module li.cursor .ep-title') as HTMLElement
if (bangumiPage !== null) {
return bangumiPage.innerText
const bangumiPageEp = dq('#eplist_module li.cursor .ep-title') as HTMLElement
if (bangumiPageEp !== null) {
return bangumiPageEp.innerText
}
const pageLink = dq('#multi_page .cur-list>ul li.on a')
if (pageLink !== null) {
return pageLink.getAttribute('title')
const videoPageEp = dq(
'#multi_page .cur-list>ul li.on a, .video-episode-card__info-playing .video-episode-card__info-title',
)
if (videoPageEp !== null) {
return videoPageEp.getAttribute('title')
}
const watchlaterPageEp = dq('.multip-list-item.multip-list-item-active')
if (watchlaterPageEp !== null) {
return watchlaterPageEp.getAttribute('title')
}
return undefined
})(),
aid: unsafeWindow.aid,
bvid: unsafeWindow.bvid,
cid: unsafeWindow.cid,
lid: document.URL.replace(/https:\/\/live\.bilibili\.com\/(blanc\/)?(\d)+/, '$2'),
lid: document.URL.replace(/https:\/\/live\.bilibili\.com\/(blanc\/)?([\d]+)/, '$2'),
// 年月日这方法名真够乱的
y: now.getFullYear().toString(),
M: (now.getMonth() + 1).toString().padStart(2, '0'), // zero-based

24
src/global.d.ts vendored
View File

@ -9,6 +9,30 @@ declare global {
const lodash: LoDashStatic
const Vue: typeof import('vue/types/umd')
type EnumEventTarget<EventTypes extends string> = EventTarget & {
addEventListener(
type: EventTypes,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void
removeEventListener(
type: EventTypes,
callback: EventListenerOrEventListenerObject | null,
options?: EventListenerOptions | boolean,
): void
}
interface NetworkInformation extends EnumEventTarget<'change'> {
downlink: number
downlinkMax: number
effectiveType: string
rtt: number
saveData: boolean
type: string
}
interface Navigator {
connection?: NetworkInformation
}
interface GitInfo {
commitHash: string
branch: string

View File

@ -1,3 +1,4 @@
import { jsDelivr } from './jsdelivr'
import { CdnConfig } from './types'
const owner = 'the1812'
@ -15,6 +16,8 @@ export const github: CdnConfig = {
sortable: `https://${host}/SortableJS/Sortable/1.14.0/Sortable.min.js`,
mdi: `https://${owner}.github.io/Bilibili-Evolved/static/mdi/mdi.css`,
streamsaver: `https://${host}/jimmywarting/StreamSaver.js/2.0.6/StreamSaver.js`,
// https://github.com/the1812/Bilibili-Evolved/pull/4521#discussion_r1402084486
ffmpeg: jsDelivr.library.ffmpeg,
},
smallLogo: `https://${host}/${owner}/Bilibili-Evolved/preview/images/logo-small.png`,
logo: `https://${host}/${owner}/Bilibili-Evolved/preview/images/logo.png`,

View File

@ -15,6 +15,11 @@ export const jsDelivr: CdnConfig = {
sortable: `https://${host}/npm/sortablejs@1.14.0/Sortable.min.js`,
mdi: `https://${host}/gh/${owner}/Bilibili-Evolved@master/docs/static/mdi/mdi.css`,
streamsaver: `https://${host}/npm/streamsaver@2.0.6/StreamSaver.min.js`,
ffmpeg: {
worker: `https://${host}/npm/@ffmpeg/ffmpeg@0.12.4/dist/umd/814.ffmpeg.js`,
core: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js`,
wasm: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm`,
},
},
smallLogo: `https://${host}/gh/${owner}/Bilibili-Evolved@preview/images/logo-small.png`,
logo: `https://${host}/gh/${owner}/Bilibili-Evolved@preview/images/logo.png`,

View File

@ -11,6 +11,11 @@ export interface CdnConfig {
sortable: string
mdi: string
streamsaver: string
ffmpeg: {
worker: string
core: string
wasm: string
}
}
smallLogo: string
logo: string