diff --git a/.browserslistrc b/.browserslistrc index a50535ab52..be10b4631d 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,10 +1,10 @@ > 1% last 2 versions not dead -chrome > 100 -edge > 100 -firefox > 103 -safari > 15 +chrome > 105 +edge > 105 +firefox > 127 +safari > 15.4 not and_chr > 0 not and_ff > 0 not and_qq > 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index e03557eee9..d95254c066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,62 @@ # 更新日志 +## v2.9.1-preview +`2024-08-15` + +包含 [v2.9.1](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.9.1) 的所有更新内容. + +✨新增 +- `查看封面` 可以为 aria2 输出提供直接的封面下载. (PR #4798 by [Oxygenくん](https://github.com/oxygenkun)) +- 新增组件 `保存视频元数据`. (PR #4840 by [WakelessSloth56](https://github.com/WakelessSloth56)) +> - 保存视频元数据为 [FFMETADATA](https://ffmpeg.org/ffmpeg-formats.html#Metadata-2) 格式 +> - 使用组件 `下载视频` 时指定 `WASM` 输出方式(插件 `下载视频 - WASM 混流输出`)可选择是否直接混流入输出文件。 +> - 保存视频章节为 OGM 格式 (https://github.com/the1812/Bilibili-Evolved/discussions/2069#discussioncomment-10110916) + +- `简化首页` 支持隐藏轮播图. (PR #4852 by [Lime](https://github.com/Liumingxun)) +- 新增组件 `添加直播间用户超链接`. (PR #4856 by [Light_Quanta](https://github.com/LightQuanta)) +> 网页版直播间右上角的房间观众和大航海界面的用户列表只可查看用户名,不可进行点击。该组件为用户头像和用户名称处添加点击效果,允许通过点击直接查看用户空间。 + +- 插件 `下载视频 - WASM 混流输出` 支持并行下载库和音视频流. (PR #4864 by [WakelessSloth56](https://github.com/WakelessSloth56)) + +## v2.9.1 +`2024-08-15` + +
+正式版用户将获得 v2.9.0-preview 的所有改动 (新功能以及一项废弃), 点击展开查看 + +✨新增 +- `简化直播间` 支持屏蔽推荐直播间. (#4787) +- 新增功能 `删除直播马赛克遮罩` (#4634, PR #4814) +> 删除观看直播时某些分区的马赛克遮罩. + +- `启用视频截图` 截出来的图支持直接复制. (此功能需要 Firefox 127 版本以上) (#4806) +- `图片批量导出`, `下载视频` 支持更多变量, 详情可在更新组件后查看设置中的说明: (#3852) + - 动态 ID, 用户 ID, 动态发布时间, 被转发动态相关数据 + - 专栏 cv 号, 专栏发布时间 + - (仅对批量视频下载 (分P / 合集) 有效) up 主名称, up 主 ID, 视频发布时间 +- `自定义顶栏` 的稍后再看和历史面板现在始终显示 "已观看" 状态. (#4346) +- `自定义顶栏` 的稍后再看, 收藏和历史面板优化了分 P 数和观看进度的展示, 详见[此处](https://github.com/the1812/Bilibili-Evolved/discussions/1866#discussioncomment-10075203). (#1866) + +🗑️废弃 +- 删除 `下载视频` 的 Toast 输出方式. + +
+ +🐛修复 +- 部分修复 `简化评论区` 在新版评论区下失效. (#4843) + - 头像框目前还没找到比较好的方式隐藏, 暂不支持. + - 时间由于 Shadow DOM 限制, 无法再挪到右上角了, `装扮 & 时间` 只对装扮有效. + - 样式实现依赖 [:host-context](https://developer.mozilla.org/en-US/docs/Web/CSS/:host-context), 因此目前还不支持 Firefox. +- 修复 `删除视频弹窗` 和 `禁用特殊弹幕样式` 在新版播放器下失效. (#4843, #4823, PR #4839 by [festoney8](https://github.com/festoney8)) +- 修复 `快捷键扩展` 的部分操作和 `启用弹幕空降` 在新版播放器下失效. + +☕开发者相关 +- 新增 Shadow DOM 系列 API (`src/core/shadow-dom.ts`), 用于处理 Shadow DOM 相关的逻辑. + - `ShadowDomObserver`: 持续观测页面上的所有 Shadow DOM. + - `ShadowDomStyles`: 支持将样式注入页面, 包含所有 Shadow DOM 内部. +- `MutationObserver` 相关的 API 支持使用 `Node` 类型作为目标. + ## v2.9.0-preview `2024-07-19` diff --git a/README.md b/README.md index a65be629de..7bc8aad691 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ ## 最低配置 - 操作系统: Windows 8.1 / macOS Big Sur - 分辨率: 1920x1080, 缩放 125% -- 浏览器: Chrome 105+ / Firefox 121+ / Safari 15.4+ (仅理论上能运行, 没测试过) +- 浏览器: Chrome 105+ / Firefox 127+ / Safari 15.4+ (仅理论上能运行, 没测试过) - 处理器: Intel Core i5-10500 / AMD Ryzen 5 3600 - 内存: 8GB - 脚本管理器: Tampermonkey 5.0 / Violentmonkey 2.18 @@ -84,7 +84,7 @@ ## 推荐配置 - 操作系统: Windows 10 / macOS Sonoma - 分辨率: 3840x2160, 缩放 200% -- 浏览器: Chrome 120+ / Firefox 121+ +- 浏览器: Chrome 120+ / Firefox 127+ - 处理器: Intel Core i7-11700 / AMD Ryzen 7 5800 - 内存: 32GB - 脚本管理器: Tampermonkey 5.0 / Violentmonkey 2.18 diff --git a/doc/donate.md b/doc/donate.md index 3ec40d9f35..73930da0f7 100644 --- a/doc/donate.md +++ b/doc/donate.md @@ -26,9 +26,13 @@ https://afdian.net/@the1812?tab=sponsor + | 时间 | 用户名 | 单号后4位 | 金额 | | ------------------- | --------------------- | --------- | ------- | +| 2024.08.15 10:18:07 | *9 | 1653 | ¥10.00 | +| 2024.07.30 13:40:22 | Z*n | 2215 | ¥5.00 | +| 2024.07.24 09:38:46 | H*g | 7776 | ¥5.00 | | 2024.07.16 20:57:01 | *h | 4344 | ¥20.00 | | 2024.06.10 11:05:25 | *兽 | 6240 | ¥5.00 | | 2024.06.01 14:30:08 | R*0 | 8974 | ¥50.00 | diff --git a/doc/features/features.json b/doc/features/features.json index 781f178c5e..eb3ee9ee81 100644 --- a/doc/features/features.json +++ b/doc/features/features.json @@ -151,6 +151,14 @@ "fullRelativePath": "../../registry/dist/components/live/original.js", "fullAbsolutePath": "registry/dist/components/live/original.js" }, + { + "type": "component", + "name": "removeLiveMaskPanel", + "displayName": "删除直播马赛克遮罩", + "description": "by [@Liki4](https://github.com/Liki4)\n\n删除观看直播时某些分区的马赛克遮罩.", + "fullRelativePath": "../../registry/dist/components/live/remove-mask-panel.js", + "fullAbsolutePath": "registry/dist/components/live/remove-mask-panel.js" + }, { "type": "component", "name": "removeLiveWatermark", @@ -547,7 +555,7 @@ "type": "component", "name": "imageExporter", "displayName": "图片批量导出", - "description": "可以批量导出某个地方的图片, 目前支持动态和专栏.\r\n\r\n动态文件名变量:\r\n- `user`: 用户名\r\n- `originalUser`: 被转发用户名, 如果不是转发类型的动态则等于 `user`\r\n- `id`: 动态 ID\r\n- `n`: 第 n 张图\r\n\r\n专栏文件名变量:\r\n- `title`: 专栏标题\r\n- `n`: 第 n 张图", + "description": "可以批量导出某个地方的图片, 目前支持动态和专栏.\r\n\r\n动态文件名变量:\r\n- `n`: 第 n 张图\r\n- `id`: 动态 ID\r\n- `user`: 用户名\r\n- `userID`: 用户 ID\r\n- 动态发布时间:\r\n - `publishYear`\r\n - `publishMonth`\r\n - `publishDay`\r\n - `publishHour`\r\n - `publishMinute`\r\n - `publishSecond`\r\n - `publishMillisecond`\r\n- 被转发的数据 (如果不是转发类型的动态, 则和上面的对应变量相同):\r\n - `originalID`: 被转发的动态 ID\r\n - `originalUser`: 被转发的用户名\r\n - `originalUserID`: 被转发用户 ID\r\n - 被转发的动态发布时间:\r\n - `originalPublishYear`\r\n - `originalPublishMonth`\r\n - `originalPublishDay`\r\n - `originalPublishHour`\r\n - `originalPublishMinute`\r\n - `originalPublishSecond`\r\n - `originalPublishMillisecond`\r\n\r\n专栏文件名变量:\r\n- `n`: 第 n 张图\r\n- `title`: 专栏标题\r\n- `cv`: 专栏 cv 号\r\n- 专栏发布时间:\r\n - `publishYear`\r\n - `publishMonth`\r\n - `publishDay`\r\n - `publishHour`\r\n - `publishMinute`\r\n - `publishSecond`\r\n - `publishMillisecond`\r\n", "fullRelativePath": "../../registry/dist/components/utils/image-exporter.js", "fullAbsolutePath": "registry/dist/components/utils/image-exporter.js" }, @@ -707,7 +715,7 @@ "type": "component", "name": "downloadVideo", "displayName": "下载视频", - "description": "在功能面板中添加下载视频支持. 请注意:\r\n- 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的.\r\n- 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁.\r\n", + "description": "在功能面板中添加下载视频支持. 请注意:\r\n- 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的.\r\n- 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁.\r\n\r\n在使用视频 (非番剧) 批量下载时, 文件的批量命名格式中可以使用以下额外变量:\r\n- `user`: UP 主用户名\r\n- `userID`: UP 主用户 ID\r\n- 视频发布时间:\r\n - `publishYear`\r\n - `publishMonth`\r\n - `publishDay`\r\n - `publishHour`\r\n - `publishMinute`\r\n - `publishSecond`\r\n - `publishMillisecond`\r\n", "fullRelativePath": "../../registry/dist/components/video/download.js", "fullAbsolutePath": "registry/dist/components/video/download.js" }, diff --git a/doc/features/features.md b/doc/features/features.md index 810fc1d516..7e143954e2 100644 --- a/doc/features/features.md +++ b/doc/features/features.md @@ -188,6 +188,17 @@ by [@TimmyOVO](https://github.com/TimmyOVO) 在直播间中提供返回原版直播间的按钮, 原版直播间将无视活动皮肤, 强制使用标准的直播页面. +### [删除直播马赛克遮罩](../../registry/dist/components/live/remove-mask-panel.js) +`removeLiveMaskPanel` + +**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/live/remove-mask-panel.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/live/remove-mask-panel.js) + +**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/live/remove-mask-panel.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/live/remove-mask-panel.js) + +by [@Liki4](https://github.com/Liki4) + +删除观看直播时某些分区的马赛克遮罩. + ### [删除直播水印](../../registry/dist/components/live/remove-watermark.js) `removeLiveWatermark` @@ -733,14 +744,43 @@ by [@snowraincloud](https://github.com/snowraincloud) 可以批量导出某个地方的图片, 目前支持动态和专栏. 动态文件名变量: -- `user`: 用户名 -- `originalUser`: 被转发用户名, 如果不是转发类型的动态则等于 `user` -- `id`: 动态 ID - `n`: 第 n 张图 +- `id`: 动态 ID +- `user`: 用户名 +- `userID`: 用户 ID +- 动态发布时间: + - `publishYear` + - `publishMonth` + - `publishDay` + - `publishHour` + - `publishMinute` + - `publishSecond` + - `publishMillisecond` +- 被转发的数据 (如果不是转发类型的动态, 则和上面的对应变量相同): + - `originalID`: 被转发的动态 ID + - `originalUser`: 被转发的用户名 + - `originalUserID`: 被转发用户 ID + - 被转发的动态发布时间: + - `originalPublishYear` + - `originalPublishMonth` + - `originalPublishDay` + - `originalPublishHour` + - `originalPublishMinute` + - `originalPublishSecond` + - `originalPublishMillisecond` 专栏文件名变量: -- `title`: 专栏标题 - `n`: 第 n 张图 +- `title`: 专栏标题 +- `cv`: 专栏 cv 号 +- 专栏发布时间: + - `publishYear` + - `publishMonth` + - `publishDay` + - `publishHour` + - `publishMinute` + - `publishSecond` + - `publishMillisecond` ### [高分辨率图片](../../registry/dist/components/utils/image-resolution.js) `imageResolution` @@ -948,6 +988,18 @@ by [@kdxcxs](https://github.com/kdxcxs) - 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的. - 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁. +在使用视频 (非番剧) 批量下载时, 文件的批量命名格式中可以使用以下额外变量: +- `user`: UP 主用户名 +- `userID`: UP 主用户 ID +- 视频发布时间: + - `publishYear` + - `publishMonth` + - `publishDay` + - `publishHour` + - `publishMinute` + - `publishSecond` + - `publishMillisecond` + ### [展开视频简介](../../registry/dist/components/video/full-description.js) `fullVideoDescription` diff --git a/doc/features/pack/pack.json b/doc/features/pack/pack.json index 51e258618e..0a9d4d3e98 100644 --- a/doc/features/pack/pack.json +++ b/doc/features/pack/pack.json @@ -123,7 +123,7 @@ "type": "component", "name": "downloadVideo", "displayName": "下载视频", - "description": "在功能面板中添加下载视频支持. 请注意:\r\n- 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的.\r\n- 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁.\r\n", + "description": "在功能面板中添加下载视频支持. 请注意:\r\n- 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的.\r\n- 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁.\r\n\r\n在使用视频 (非番剧) 批量下载时, 文件的批量命名格式中可以使用以下额外变量:\r\n- `user`: UP 主用户名\r\n- `userID`: UP 主用户 ID\r\n- 视频发布时间:\r\n - `publishYear`\r\n - `publishMonth`\r\n - `publishDay`\r\n - `publishHour`\r\n - `publishMinute`\r\n - `publishSecond`\r\n - `publishMillisecond`\r\n", "fullRelativePath": "../../registry/dist/components/video/download.js", "fullAbsolutePath": "registry/dist/components/video/download.js" }, diff --git a/registry/lib/components/live/remove-mask-panel/index.ts b/registry/lib/components/live/remove-mask-panel/index.ts new file mode 100644 index 0000000000..177f38862f --- /dev/null +++ b/registry/lib/components/live/remove-mask-panel/index.ts @@ -0,0 +1,33 @@ +import { defineComponentMetadata } from '@/components/define' +import { liveUrls } from '@/core/utils/urls' + +const id = 'web-player-module-area-mask-panel' +const entry = async () => { + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if ((node as Element).id === id) { + node.parentNode?.removeChild(node) + observer.disconnect() + } + }) + }) + }) + observer.observe(document.body, { childList: true, subtree: true }) +} + +export const component = defineComponentMetadata({ + name: 'removeLiveMaskPanel', + displayName: '删除直播马赛克遮罩', + author: { + name: 'Liki4', + link: 'https://github.com/Liki4', + }, + tags: [componentsTags.live, componentsTags.style], + description: { + 'zh-CN': '删除观看直播时某些分区的马赛克遮罩.', + }, + entry, + reload: entry, + urlInclude: liveUrls, +}) diff --git a/registry/lib/components/style/custom-navbar/favorites/NavbarFavorites.vue b/registry/lib/components/style/custom-navbar/favorites/NavbarFavorites.vue index de6905962f..a92531da03 100644 --- a/registry/lib/components/style/custom-navbar/favorites/NavbarFavorites.vue +++ b/registry/lib/components/style/custom-navbar/favorites/NavbarFavorites.vue @@ -103,7 +103,10 @@ const favoriteItemMapper = (item: any): FavoritesItemInfo => ({ title: item.title, description: item.intro, duration: item.duration, - durationText: formatDuration(item.duration), + durationText: + item.page > 1 + ? `${formatDuration(item.duration)} / ${item.page}P` + : formatDuration(item.duration), playCount: item.cnt_info.play, danmakuCount: item.cnt_info.danmaku, upName: item.upper.name, diff --git a/registry/lib/components/style/custom-navbar/history/NavbarHistory.vue b/registry/lib/components/style/custom-navbar/history/NavbarHistory.vue index a2c2afcbcc..4b50725dee 100644 --- a/registry/lib/components/style/custom-navbar/history/NavbarHistory.vue +++ b/registry/lib/components/style/custom-navbar/history/NavbarHistory.vue @@ -60,16 +60,6 @@ class="progress" :style="{ width: h.progress * 100 + '%' }" > -
- {{ h.progress >= 1 ? '已看完' : h.progressText }} -
-
- {{ h.liveStatus === 1 ? '直播中' : '未开播' }} -
{{ h.durationText }}
{{ @@ -89,8 +79,25 @@ >
{{ h.upName }}
-
- {{ h.timeText }} +
+
+ {{ h.progress >= 0.95 ? '已看完' : h.progressText }} +
+
+ {{ h.liveStatus === 1 ? '直播中' : '未开播' }} +
+ | +
+ {{ h.timeText }} +
@@ -405,22 +412,6 @@ export default Vue.extend({ filter: invert(0.9); } } - .duration { - left: $padding; - bottom: $padding; - padding: 0 6px; - } - .live-status { - &.on { - background-color: var(--theme-color); - color: var(--foreground-color); - } - } - .progress-number { - left: $padding; - top: $padding; - padding: 0 6px; - } .progress { position: absolute; bottom: 0; @@ -448,7 +439,7 @@ export default Vue.extend({ } } .up, - .time { + .history-info { font-size: 11px; opacity: 0.75; align-self: center; @@ -479,10 +470,23 @@ export default Vue.extend({ } } } - .time { + .history-info { + @include h-center(4px); font-size: 11px; grid-area: time; padding-right: 6px; + &-separator { + margin: 0 4px; + } + } + .progress-number, + .live-status { + @include single-line(); + } + .live-status { + &.on { + color: var(--theme-color); + } } } } diff --git a/registry/lib/components/style/custom-navbar/history/types.ts b/registry/lib/components/style/custom-navbar/history/types.ts index c12e007ad0..0767b42f16 100644 --- a/registry/lib/components/style/custom-navbar/history/types.ts +++ b/registry/lib/components/style/custom-navbar/history/types.ts @@ -1,5 +1,4 @@ import { getJsonWithCredentials, bilibiliApi } from '@/core/ajax' -import { fixed } from '@/core/utils' import { formatDuration } from '@/core/utils/formatters' /** 历史项目类型, 值为 API 中的 `history.business` */ @@ -107,13 +106,13 @@ const getTimeData = () => { } } const formatTime = (date: Date) => { - const { yesterday } = getTimeData() + const { yesterday, today } = getTimeData() const timestamp = Number(date) if (timestamp >= yesterday) { - return `${date.getHours().toString().padStart(2, '0')}:${date - .getMinutes() + return `${timestamp >= today ? '今天' : '昨天'} ${date + .getHours() .toString() - .padStart(2, '0')}` + .padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}` } return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date .getDate() @@ -154,7 +153,9 @@ const parseHistoryItem = (item: any): HistoryItem => { cover, covers: item.covers?.map(https) ?? [], progress, - progressText: Number.isNaN(progress) ? null : `${fixed(progress * 100, 1)}%`, + progressText: Number.isNaN(progress) + ? null + : `${formatDuration(item.progress)} / ${formatDuration(item.duration)}`, duration: item.duration, durationText: item.duration ? formatDuration(item.duration) : null, upName: item.author_name, diff --git a/registry/lib/components/style/custom-navbar/watchlater/NavbarWatchlater.vue b/registry/lib/components/style/custom-navbar/watchlater/NavbarWatchlater.vue index f4f3540e22..84da52c192 100644 --- a/registry/lib/components/style/custom-navbar/watchlater/NavbarWatchlater.vue +++ b/registry/lib/components/style/custom-navbar/watchlater/NavbarWatchlater.vue @@ -34,18 +34,24 @@
{{ card.durationText }}
-
已观看
+
+ {{ card.currentPage }}P / {{ card.totalPages }}P +
+
{{ card.title }} - - -
{{ card.upName }}
-
+
+ + +
{{ card.upName }}
+
+
已观看
+
@@ -73,6 +79,9 @@ interface WatchlaterCard { upName: string upFaceUrl: string upID: number + currentPage?: number + totalPages: number + percent: number } export default Vue.extend({ components: { @@ -125,28 +134,31 @@ export default Vue.extend({ return `https://www.bilibili.com/medialist/play/watchlater/${item.bvid}` } const cards = rawList.map(item => { + const currentPage = item.pages?.find(p => p.cid === item.cid) + const duration = currentPage?.duration ?? item.duration const href = (() => { - if (item.pages === undefined || !this.redirect) { + if (!currentPage || !this.redirect) { return getLink(item) } - const pages = item.pages.map(it => it.cid) - const page = item.cid === 0 ? 1 : pages.indexOf(item.cid) + 1 - + const { page } = currentPage return page <= 1 ? getLink(item) : `${getLink(item)}?p=${page}` })() - const percent = Math.round((1000 * item.progress) / item.duration) / 1000 + const percent = Math.round((1000 * item.progress) / duration) / 1000 + return { aid: item.aid, href, coverUrl: item.pic.replace('http:', 'https:'), - durationText: formatDuration(item.duration), - duration: item.duration, - // percent: `${fixed(percent * 100)}%`, + durationText: formatDuration(duration), + duration, complete: item.progress < 0 || percent > 0.95, // 进度过95%算看完, -1值表示100% title: item.title, upName: item.owner.name, upFaceUrl: item.owner.face.replace('http:', 'https:'), upID: item.owner.mid, + currentPage: currentPage?.page, + totalPages: item.videos, + percent, } as WatchlaterCard }) this.cards = cards @@ -291,6 +303,11 @@ export default Vue.extend({ border-radius: 8px 0 0 8px; position: relative; $padding: 6px; + .floating { + position: absolute; + opacity: 0; + font-size: 11px; + } .remove { top: $padding; left: $padding; @@ -301,20 +318,22 @@ export default Vue.extend({ bottom: $padding; padding: 0 6px; } - .viewed { - white-space: nowrap; - right: $padding; + .pages { top: $padding; + right: $padding; padding: 0 6px; } - .floating { - position: absolute; - opacity: 0; - font-size: 11px; - } .cover { object-fit: cover; } + .progress { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + border-radius: 1px; + background-color: var(--theme-color); + } } &:hover { .floating { @@ -333,17 +352,21 @@ export default Vue.extend({ color: var(--theme-color) !important; } } - // .info { - // grid-area: info; - // display: flex; - // align-items: center; - // justify-content: space-around; - // margin: 8px; - // } + .info { + display: flex; + justify-content: space-between; + align-items: flex-end; + grid-area: info; + margin: 6px 8px; + .viewed { + opacity: 0.75; + font-size: 11px; + margin: 2px 0; + } + } .up { flex: 0 1 auto; padding: 2px 10px 2px 2px; - margin: 0 8px 6px; justify-self: start; align-self: center; max-width: calc(100% - 16px); diff --git a/registry/lib/components/style/simplify/comments/comments-v3.scss b/registry/lib/components/style/simplify/comments/comments-v3.scss new file mode 100644 index 0000000000..b9c428e9a7 --- /dev/null +++ b/registry/lib/components/style/simplify/comments/comments-v3.scss @@ -0,0 +1,59 @@ +@import 'common'; + +$prefix: 'simplifyComments-switch'; + +:host-context(.comment-m-v1) { + &:host(bili-rich-text) { + #contents img, #contents a i { + max-width: 1.4em; + max-height: 1.4em; + } + } + + &:host-context(body.#{$prefix}-replyEditor) { + &:host(bili-comment-box) { + #pub button { + font-size: 14px; + } + } + &:host(bili-checkbox) { + #label { + font-size: 15px; + } + } + &:host(bili-comment-textarea) { + #input { + font-size: 13px; + } + #input, + #input::placeholder { + line-height: normal; + } + } + } + &:host-context(body.#{$prefix}-userLevel):host(bili-comment-user-info) { + #user-level { + display: none; + } + } + &:host-context(body.#{$prefix}-decorateAndTime):host(bili-comment-renderer) { + bili-comment-user-sailing-card { + display: none; + } + } + &:host-context(body.#{$prefix}-fansMedal):host(bili-comment-user-info) { + bili-comment-user-medal { + display: none; + } + } + &:host-context(body.#{$prefix}-subReplyNewLine):host(bili-comment-reply-renderer) { + bili-comment-user-info { + display: block; + } + } + &:host-context(body.#{$prefix}-eventBanner):host(bili-comments-header-renderer) { + bili-comments-notice { + display: none; + } + } +} diff --git a/registry/lib/components/style/simplify/comments/index.ts b/registry/lib/components/style/simplify/comments/index.ts index 2f87ba0265..8efcf7e19a 100644 --- a/registry/lib/components/style/simplify/comments/index.ts +++ b/registry/lib/components/style/simplify/comments/index.ts @@ -50,14 +50,19 @@ export const component = wrapSwitchOptions({ }, true, ) + + const { ShadowDomStyles } = await import('@/core/shadow-dom') + const v3Styles = await import('./comments-v3.scss').then(m => m.default) + const shadowDom = new ShadowDomStyles() + shadowDom.addStyle(v3Styles) }, instantStyles: [ { - name, + name: `${name}v1`, style: () => import('./comments.scss'), }, { - name, + name: `${name}v2`, style: () => import('./comments-v2.scss'), }, ], diff --git a/registry/lib/components/style/simplify/live/index.ts b/registry/lib/components/style/simplify/live/index.ts index e6bab90fa4..ee2c8803e9 100644 --- a/registry/lib/components/style/simplify/live/index.ts +++ b/registry/lib/components/style/simplify/live/index.ts @@ -85,6 +85,10 @@ export const component = wrapSwitchOptions({ defaultValue: false, displayName: '高能榜提示', }, + recommendedRooms: { + defaultValue: false, + displayName: '推荐直播间', + }, skin: { defaultValue: false, displayName: '房间皮肤', diff --git a/registry/lib/components/style/simplify/live/live.scss b/registry/lib/components/style/simplify/live/live.scss index 98fd9952db..0bdf92d533 100644 --- a/registry/lib/components/style/simplify/live/live.scss +++ b/registry/lib/components/style/simplify/live/live.scss @@ -27,7 +27,8 @@ $prefix: 'simplifyLiveroom-switch'; &-pk .chaos-pk, &-pk .awesome-pk-box, &-topRank .chat-item.top3-notice, - &-topRank .chat-item .rank-icon { + &-topRank .chat-item .rank-icon, + &-recommendedRooms .left-container .room-info-ctnr { display: none !important; } } diff --git a/registry/lib/components/style/special-danmaku/special-danmaku.scss b/registry/lib/components/style/special-danmaku/special-danmaku.scss index dcae902312..0fd85e3dd7 100644 --- a/registry/lib/components/style/special-danmaku/special-danmaku.scss +++ b/registry/lib/components/style/special-danmaku/special-danmaku.scss @@ -1,9 +1,16 @@ body.disable-highlight-danmaku-style { .bili-danmaku-x-high, - .bili-danmaku-x-high-top, + .bili-danmaku-x-high-top { + display: flex !important; + } .bili-dm.bili-high, .b-danmaku-high { display: block !important; + } + .bili-danmaku-x-high, + .bili-danmaku-x-high-top, + .bili-dm.bili-high, + .b-danmaku-high { padding: 0 !important; line-height: 1.125 !important; .bili-danmaku-x-high-icon, diff --git a/registry/lib/components/utils/image-exporter/Widget.vue b/registry/lib/components/utils/image-exporter/Widget.vue index bb4583689f..62b6b37dcf 100644 --- a/registry/lib/components/utils/image-exporter/Widget.vue +++ b/registry/lib/components/utils/image-exporter/Widget.vue @@ -13,7 +13,7 @@ import { getComponentSettings } from '@/core/settings' import { Toast } from '@/core/toast' import { retrieveImageUrl } from '@/core/utils' import { logError } from '@/core/utils/log' -import { formatTitle } from '@/core/utils/title' +import { formatTitle, getTitleVariablesFromDate } from '@/core/utils/title' import { DefaultWidget } from '@/ui' export default Vue.extend({ @@ -31,25 +31,45 @@ export default Vue.extend({ const toast = Toast.info('下载中...', '导出图片') this.busy = true try { + const publishDate = getTitleVariablesFromDate( + new Date( + // eslint-disable-next-line no-underscore-dangle + (unsafeWindow.__INITIAL_STATE__?.readInfo?.publish_time ?? 0) * 1000, + ), + ) + const variables = { + cv: document.URL.match(/read\/cv(\d+)/)?.at(1), + publishYear: publishDate.year, + publishMonth: publishDate.month, + publishDay: publishDate.day, + publishHour: publishDate.hour, + publishMinute: publishDate.minute, + publishSecond: publishDate.second, + publishMillisecond: publishDate.millisecond, + } + const images: { name: string; extension: string; url: string }[] = [] const bannerElement = dq('.banner-image .card-image__image') as HTMLDivElement const bannerUrl = retrieveImageUrl(bannerElement) if (bannerUrl) { images.push({ ...bannerUrl, - name: `${formatTitle(columnFormat, false, { n: '1' })}${bannerUrl.extension}`, + name: `${formatTitle(columnFormat, false, { n: '1', ...variables })}${ + bannerUrl.extension + }`, }) console.log(bannerElement, bannerUrl, images) } - const articleImages = dqa('.article-content .img-box img:not([class*="cut-off-"])') + const articleImages = dqa('.article-content :is(img.normal-img, .normal-img img)') articleImages.forEach((image: HTMLElement) => { const url = retrieveImageUrl(image) if (url) { images.push({ ...url, - name: `${formatTitle(columnFormat, false, { n: (images.length + 1).toString() })}${ - url.extension - }`, + name: `${formatTitle(columnFormat, false, { + n: (images.length + 1).toString(), + ...variables, + })}${url.extension}`, }) } }) @@ -68,7 +88,8 @@ export default Vue.extend({ ) const pack = new DownloadPackage() imageBlobs.forEach((blob, index) => pack.add(images[index].name, blob)) - await pack.emit(`${formatTitle(columnFormat, false, { n: '' })}.zip`) + + await pack.emit(`${formatTitle(columnFormat, false, { n: '', ...variables })}.zip`) } catch (error) { logError(error) } finally { diff --git a/registry/lib/components/utils/image-exporter/feed.ts b/registry/lib/components/utils/image-exporter/feed.ts index 2e98ff9c8e..36d0654b29 100644 --- a/registry/lib/components/utils/image-exporter/feed.ts +++ b/registry/lib/components/utils/image-exporter/feed.ts @@ -3,8 +3,8 @@ import { ComponentEntry } from '@/components/types' import { getBlob } from '@/core/ajax' import { DownloadPackage } from '@/core/download' import { Toast } from '@/core/toast' -import { matchUrlPattern, retrieveImageUrl } from '@/core/utils' -import { formatTitle } from '@/core/utils/title' +import { getVue2Data, matchUrlPattern, retrieveImageUrl } from '@/core/utils' +import { formatTitle, getTitleVariablesFromDate } from '@/core/utils/title' import { feedsUrls } from '@/core/utils/urls' import { Options } from '.' @@ -36,6 +36,38 @@ export const setupFeedImageExporter: ComponentEntry = async ({ } const toast = Toast.info('下载中...', '导出图片') let downloadedCount = 0 + + const vueData = getVue2Data(card.element) + const authorModule = lodash.get(vueData, 'data.modules.module_author', {}) + const repostAuthorModule = lodash.get(vueData, 'data.orig.modules.module_author', {}) + const date = getTitleVariablesFromDate(new Date(authorModule.pub_ts * 1000)) + const repostDate = getTitleVariablesFromDate( + new Date((repostAuthorModule.pub_ts ?? authorModule.pub_ts) * 1000), + ) + const variables = { + id: card.id, + user: card.username, + userID: authorModule.mid?.toString(), + originalUser: (card as RepostFeedsCard).repostUsername ?? card.username, + originalUserID: repostAuthorModule.mid?.toString() ?? authorModule.mid?.toString(), + originalID: lodash.get(vueData, 'data.orig.id_str', card.id), + + publishYear: date.year, + publishMonth: date.month, + publishDay: date.day, + publishHour: date.hour, + publishMinute: date.minute, + publishSecond: date.second, + publishMillisecond: date.millisecond, + + originalPublishYear: repostDate.year, + originalPublishMonth: repostDate.month, + originalPublishDay: repostDate.day, + originalPublishHour: repostDate.hour, + originalPublishMinute: repostDate.minute, + originalPublishSecond: repostDate.second, + originalPublishMillisecond: repostDate.millisecond, + } const imageBlobs = await Promise.all( imageUrls.map(async ({ url }) => { const blob = await getBlob(url) @@ -48,10 +80,8 @@ export const setupFeedImageExporter: ComponentEntry = async ({ const { feedFormat } = options imageBlobs.forEach((blob, index) => { const titleData = { - user: card.username, - id: card.id, - originalUser: (card as RepostFeedsCard).repostUsername ?? card.username, n: (index + 1).toString(), + ...variables, } pack.add( `${formatTitle(feedFormat, false, titleData)}${imageUrls[index].extension}`, @@ -60,10 +90,8 @@ export const setupFeedImageExporter: ComponentEntry = async ({ }) toast.close() const packTitleData = { - user: card.username, - id: card.id, - originalUser: (card as RepostFeedsCard).repostUsername ?? card.username, n: '', + ...variables, } await pack.emit(`${formatTitle(feedFormat, false, packTitleData)}.zip`) }, diff --git a/registry/lib/components/utils/image-exporter/index.md b/registry/lib/components/utils/image-exporter/index.md index 8505dd017d..34800003ff 100644 --- a/registry/lib/components/utils/image-exporter/index.md +++ b/registry/lib/components/utils/image-exporter/index.md @@ -1,11 +1,40 @@ 可以批量导出某个地方的图片, 目前支持动态和专栏. 动态文件名变量: -- `user`: 用户名 -- `originalUser`: 被转发用户名, 如果不是转发类型的动态则等于 `user` -- `id`: 动态 ID - `n`: 第 n 张图 +- `id`: 动态 ID +- `user`: 用户名 +- `userID`: 用户 ID +- 动态发布时间: + - `publishYear` + - `publishMonth` + - `publishDay` + - `publishHour` + - `publishMinute` + - `publishSecond` + - `publishMillisecond` +- 被转发的数据 (如果不是转发类型的动态, 则和上面的对应变量相同): + - `originalID`: 被转发的动态 ID + - `originalUser`: 被转发的用户名 + - `originalUserID`: 被转发用户 ID + - 被转发的动态发布时间: + - `originalPublishYear` + - `originalPublishMonth` + - `originalPublishDay` + - `originalPublishHour` + - `originalPublishMinute` + - `originalPublishSecond` + - `originalPublishMillisecond` 专栏文件名变量: +- `n`: 第 n 张图 - `title`: 专栏标题 -- `n`: 第 n 张图 \ No newline at end of file +- `cv`: 专栏 cv 号 +- 专栏发布时间: + - `publishYear` + - `publishMonth` + - `publishDay` + - `publishHour` + - `publishMinute` + - `publishSecond` + - `publishMillisecond` diff --git a/registry/lib/components/utils/image-exporter/index.ts b/registry/lib/components/utils/image-exporter/index.ts index e855bbfb90..f091430905 100644 --- a/registry/lib/components/utils/image-exporter/index.ts +++ b/registry/lib/components/utils/image-exporter/index.ts @@ -11,10 +11,12 @@ const options = defineOptionsMetadata({ columnFormat: { defaultValue: '[title][ - n]', displayName: '专栏图片命名格式', + multiline: true, }, feedFormat: { defaultValue: '[user][ - id][ - n]', displayName: '动态图片命名格式', + multiline: true, }, }) diff --git a/registry/lib/components/utils/keymap/actions.ts b/registry/lib/components/utils/keymap/actions.ts index 7a3f6e5701..89c7add0c3 100644 --- a/registry/lib/components/utils/keymap/actions.ts +++ b/registry/lib/components/utils/keymap/actions.ts @@ -136,13 +136,13 @@ export const builtInActions: Record = { 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, .toolbar .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, .video-toolbar-container .video-coin', ), }, favorite: { displayName: '收藏', run: useClickElement( - '.video-toolbar .collect, .video-toolbar-module .fav-box, .play-options-ul > li:nth-child(3), .video-toolbar-v1 .collect', + '.video-toolbar .collect, .video-toolbar-module .fav-box, .play-options-ul > li:nth-child(3), .video-toolbar-v1 .collect, .video-toolbar-container .video-fav', ), }, pause: { @@ -157,7 +157,7 @@ export const builtInActions: Record = { return (context: KeyBindingActionContext) => { const { event } = context const likeButton = dq( - '.video-toolbar .like, .tool-bar .like-info, .video-toolbar-v1 .like, .toolbar .like', + '.video-toolbar .like, .tool-bar .like-info, .video-toolbar-v1 .like, .toolbar .like, .video-toolbar-container .video-like', ) as HTMLSpanElement if (!likeButton) { return false diff --git a/registry/lib/components/video/danmaku/airborne/airborne.scss b/registry/lib/components/video/danmaku/airborne/airborne.scss index bd2bb4cf97..7e5e869462 100644 --- a/registry/lib/components/video/danmaku/airborne/airborne.scss +++ b/registry/lib/components/video/danmaku/airborne/airborne.scss @@ -1,5 +1,6 @@ .bili-dm, -.b-danmaku { +.b-danmaku, +.bili-danmaku-x-dm { &.airborne { text-decoration: underline; cursor: pointer; diff --git a/registry/lib/components/video/danmaku/airborne/index.ts b/registry/lib/components/video/danmaku/airborne/index.ts index ba1bca2ec4..ea07f9fe4b 100644 --- a/registry/lib/components/video/danmaku/airborne/index.ts +++ b/registry/lib/components/video/danmaku/airborne/index.ts @@ -51,7 +51,11 @@ export const component = defineComponentMetadata({ return } const target = e.target as HTMLElement - if (!['b-danmaku', 'bili-dm'].some(token => target.classList.contains(token))) { + if ( + !['b-danmaku', 'bili-dm', 'bili-danmaku-x-dm'].some(token => + target.classList.contains(token), + ) + ) { return } const time = getAirborneTime(target.textContent) diff --git a/registry/lib/components/video/download/DownloadVideo.vue b/registry/lib/components/video/download/DownloadVideo.vue index 61b64505f6..1e66bc16ad 100644 --- a/registry/lib/components/video/download/DownloadVideo.vue +++ b/registry/lib/components/video/download/DownloadVideo.vue @@ -99,7 +99,6 @@ 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' -import { toastOutput } from './outputs/toast' import { streamSaverOutput } from './outputs/stream-saver' import { DownloadVideoAction, @@ -125,7 +124,6 @@ const [apis] = registerAndGetData('downloadVideo.apis', [ ] as DownloadVideoApi[]) const [assets] = registerAndGetData('downloadVideo.assets', [] as DownloadVideoAssets[]) const [outputs] = registerAndGetData('downloadVideo.outputs', [ - toastOutput, streamSaverOutput, ] as DownloadVideoOutput[]) const { basicConfig } = getComponentSettings('downloadVideo').options as { diff --git a/registry/lib/components/video/download/index.md b/registry/lib/components/video/download/index.md index fc5f39dbd0..ac395de642 100644 --- a/registry/lib/components/video/download/index.md +++ b/registry/lib/components/video/download/index.md @@ -1,3 +1,15 @@ 在功能面板中添加下载视频支持. 请注意: - 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的. - 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁. + +在使用视频 (非番剧) 批量下载时, 文件的批量命名格式中可以使用以下额外变量: +- `user`: UP 主用户名 +- `userID`: UP 主用户 ID +- 视频发布时间: + - `publishYear` + - `publishMonth` + - `publishDay` + - `publishHour` + - `publishMinute` + - `publishSecond` + - `publishMillisecond` diff --git a/registry/lib/components/video/download/inputs/video/batch.ts b/registry/lib/components/video/download/inputs/video/batch.ts index 43a0f1c39c..280acc7d43 100644 --- a/registry/lib/components/video/download/inputs/video/batch.ts +++ b/registry/lib/components/video/download/inputs/video/batch.ts @@ -2,7 +2,7 @@ import { getJsonWithCredentials } from '@/core/ajax' import { getGeneralSettings } from '@/core/settings' import { formatDuration, formatNumber } from '@/core/utils/formatters' import { logError } from '@/core/utils/log' -import { formatTitle } from '@/core/utils/title' +import { formatTitle, getTitleVariablesFromDate } from '@/core/utils/title' import { videoUrls } from '@/core/utils/urls' import { DownloadVideoInput } from '../../types' import { createEpisodesPicker, EpisodeItem } from '../episode-item' @@ -22,11 +22,12 @@ export const videoBatchInput: DownloadVideoInput = { logError(`获取视频选集列表失败, message = ${json.message}`) return [] } - const { pages } = json.data + const { pages, owner, pubdate } = json.data if (pages === undefined) { logError('获取视频选集列表失败, 没有找到选集信息.') return [] } + const date = getTitleVariablesFromDate(new Date(pubdate * 1000)) return pages.map((page: any, index: number) => { const key = page.cid const title = `P${page.page} ${page.part}` @@ -38,9 +39,18 @@ export const videoBatchInput: DownloadVideoInput = { inputItem: { allowQualityDrop: true, title: formatTitle(getGeneralSettings().batchFilenameFormat, false, { - cid: page.cid, n: formatNumber(page.page, pages.length), + cid: page.cid, ep: page.part, + user: owner?.name, + userID: owner?.mid?.toString(), + publishYear: date.year, + publishMonth: date.month, + publishDay: date.day, + publishHour: date.hour, + publishMinute: date.minute, + publishSecond: date.second, + publishMillisecond: date.millisecond, }), cid: page.cid, aid, @@ -64,6 +74,7 @@ export const videoSeasonBatchInput: DownloadVideoInput = { logError(`获取视频合集列表失败, message = ${json.message}`) return [] } + const owner = lodash.get(json, 'data.View.owner', {}) const sections: { episodes: any[] }[] = lodash.get(json, 'data.View.ugc_season.sections', []) if (sections.length === 0) { return [] @@ -77,6 +88,7 @@ export const videoSeasonBatchInput: DownloadVideoInput = { const key = episode.cid const page = currentIndex + 1 const title = `P${page} ${episode.title}` + const date = getTitleVariablesFromDate(new Date(episode.arc.pubdate * 1000)) return { key, title, @@ -85,10 +97,19 @@ export const videoSeasonBatchInput: DownloadVideoInput = { inputItem: { allowQualityDrop: true, title: formatTitle(getGeneralSettings().batchFilenameFormat, false, { + n: formatNumber(page, totalEpisodesLength), title: episode.page.part, cid: episode.cid, - n: formatNumber(page, totalEpisodesLength), ep: episode.title, + user: owner.name, + userID: owner.mid?.toString(), + publishYear: date.year, + publishMonth: date.month, + publishDay: date.day, + publishHour: date.hour, + publishMinute: date.minute, + publishSecond: date.second, + publishMillisecond: date.millisecond, }), cid: episode.cid, aid: episode.aid, diff --git a/registry/lib/components/video/download/outputs/toast.ts b/registry/lib/components/video/download/outputs/toast.ts deleted file mode 100644 index 6040efe0a5..0000000000 --- a/registry/lib/components/video/download/outputs/toast.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Toast } from '@/core/toast' -import { DownloadVideoOutput } from '../types' - -export const toastOutput: DownloadVideoOutput = { - name: 'toast', - displayName: 'Toast', - description: - '弹一条消息显示出下载链接, 右键新标签页打开就可以下载. 链接有 referer 限制, 复制无用, 且不能保留视频文件名.', - runAction: async action => { - const fragments = action.infos.flatMap(it => it.titledFragments) - const urls = fragments.map(f => f.url).join('\n') - Toast.show( - fragments - .map(f => `${f.title}`) - .join('\n'), - '下载视频', - ) - console.log(urls) - console.log(action) - }, -} diff --git a/registry/lib/components/video/player/screenshot/VideoScreenshot.vue b/registry/lib/components/video/player/screenshot/VideoScreenshot.vue index 1215d64c62..c68ab458c7 100644 --- a/registry/lib/components/video/player/screenshot/VideoScreenshot.vue +++ b/registry/lib/components/video/player/screenshot/VideoScreenshot.vue @@ -13,7 +13,10 @@ - + {{ time }} @@ -23,29 +26,48 @@ - diff --git a/src/components/settings-panel/index.ts b/src/components/settings-panel/index.ts index c8c8952433..cf28504d5e 100644 --- a/src/components/settings-panel/index.ts +++ b/src/components/settings-panel/index.ts @@ -45,10 +45,12 @@ const options = defineOptionsMetadata({ filenameFormat: { defaultValue: '[title][ - ep]', displayName: '文件命名格式', + multiline: true, }, batchFilenameFormat: { defaultValue: '[n - ][ep]', displayName: '批量命名格式', + multiline: true, }, downloadPackageEmitMode: { defaultValue: DownloadPackageEmitMode.Packed, diff --git a/src/components/types.ts b/src/components/types.ts index 9e7ddb8c8c..3eaee6ebce 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -59,6 +59,8 @@ export interface OptionMetadata { hidden?: boolean /** 设为 `true` 时, 将用颜色选取器替代文本框 */ color?: boolean + /** 设为 `true` 时, 使用多行文本框 */ + multiline?: boolean /** 设置范围, 可以显示为一个滑动条 */ slider?: { min?: number diff --git a/src/core/core-apis.ts b/src/core/core-apis.ts index 9c874e46a9..aa400dfd19 100644 --- a/src/core/core-apis.ts +++ b/src/core/core-apis.ts @@ -17,6 +17,7 @@ import * as spinQuery from '@/core/spin-query' import * as style from '@/core/style' import * as textColor from '@/core/text-color' import * as settings from '@/core/settings' +import * as shadowDom from '@/core/shadow-dom' import * as userInfo from '@/core/user-info' import * as version from '@/core/version' import * as commonUtils from '@/core/utils' @@ -59,6 +60,7 @@ export const coreApis = { userInfo, version, settings, + shadowDom, toast, themeColor, utils: { @@ -99,6 +101,7 @@ export const externalApis = { ...textColor, ...userInfo, ...version, + ...shadowDom, settingsApis: settings, get settings() { return settings.settings diff --git a/src/core/observer.ts b/src/core/observer.ts index 729a28d6bd..a95d561096 100644 --- a/src/core/observer.ts +++ b/src/core/observer.ts @@ -3,17 +3,24 @@ import { select } from './spin-query' import { matchCurrentPage, playerUrls } from './utils/urls' type ObserverTarget = string | Element[] | Element -export const resolveTargets = (target: ObserverTarget) => { +type MutationObserverTarget = string | Node[] | Node +export const resolveTargets = < + T extends ObserverTarget | MutationObserverTarget, + ResultType = T extends ObserverTarget ? Element[] : Node[], +>( + target: T, +): ResultType => { if (typeof target === 'string') { - return dqa(target) + return dqa(target) as ResultType } if (Array.isArray(target)) { - return target + return target as ResultType } - return [target] + return [target] as ResultType } + export const mutationObserve = ( - targets: Element[], + targets: Node[], config: MutationObserverInit, callback: MutationCallback, ) => { @@ -26,7 +33,7 @@ export const mutationObserve = ( * @param target 监听目标 * @param callback 回调函数 */ -export const childList = (target: ObserverTarget, callback: MutationCallback) => +export const childList = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { @@ -40,7 +47,7 @@ export const childList = (target: ObserverTarget, callback: MutationCallback) => * @param target 监听目标 * @param callback 回调函数 */ -export const childListSubtree = (target: ObserverTarget, callback: MutationCallback) => +export const childListSubtree = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { @@ -54,7 +61,7 @@ export const childListSubtree = (target: ObserverTarget, callback: MutationCallb * @param target 监听目标 * @param callback 回调函数 */ -export const attributes = (target: ObserverTarget, callback: MutationCallback) => +export const attributes = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { @@ -68,7 +75,7 @@ export const attributes = (target: ObserverTarget, callback: MutationCallback) = * @param target 监听目标 * @param callback 回调函数 */ -export const attributesSubtree = (target: ObserverTarget, callback: MutationCallback) => +export const attributesSubtree = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { @@ -82,7 +89,7 @@ export const attributesSubtree = (target: ObserverTarget, callback: MutationCall * @param target 监听目标 * @param callback 回调函数 */ -export const characterData = (target: ObserverTarget, callback: MutationCallback) => +export const characterData = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { @@ -97,7 +104,7 @@ export const characterData = (target: ObserverTarget, callback: MutationCallback * @param target 监听目标 * @param callback 回调函数 */ -export const characterDataSubtree = (target: ObserverTarget, callback: MutationCallback) => +export const characterDataSubtree = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { @@ -114,7 +121,7 @@ export const characterDataSubtree = (target: ObserverTarget, callback: MutationC * @param target 监听目标 * @param callback 回调函数 */ -export const allMutationsOn = (target: ObserverTarget, callback: MutationCallback) => +export const allMutationsOn = (target: MutationObserverTarget, callback: MutationCallback) => mutationObserve( resolveTargets(target), { diff --git a/src/core/shadow-dom.ts b/src/core/shadow-dom.ts new file mode 100644 index 0000000000..98c788d0f1 --- /dev/null +++ b/src/core/shadow-dom.ts @@ -0,0 +1,192 @@ +import { allMutations, childListSubtree } from './observer' +import { addStyle } from './style' +import { deleteValue, getRandomId } from './utils' + +enum ShadowRootEvents { + Added = 'shadowRootAdded', + Removed = 'shadowRootRemoved', + Updated = 'shadowRootUpdated', +} +interface ShadowRootEntry { + shadowRoot: ShadowRoot + observer: MutationObserver +} +interface ShadowRootStyleEntry { + shadowRoot: ShadowRoot +} +export class ShadowDomObserver extends EventTarget { + static enforceOpenRoot() { + const originalAttachShadow = Element.prototype.attachShadow + Element.prototype.attachShadow = function attachShadow(options: ShadowRootInit) { + return originalAttachShadow.call(this, { + ...options, + mode: 'open', + }) + } + } + + private observing = false + + entries: ShadowRootEntry[] = [] + + constructor() { + super() + } + + protected queryAllShadowRoots( + root: DocumentFragment | Element = document.body, + deep = false, + ): ShadowRoot[] { + return [root, ...root.querySelectorAll('*')] + .filter((e): e is Element => e instanceof Element && e.shadowRoot !== null) + .flatMap(e => { + if (deep) { + return [e.shadowRoot, ...this.queryAllShadowRoots(e.shadowRoot)] + } + return [e.shadowRoot] + }) + } + + protected mutationHandler(records: MutationRecord[]) { + records.forEach(record => { + record.removedNodes.forEach(node => { + if (node instanceof Element && node.shadowRoot !== null) { + this.removeEntry(node.shadowRoot) + } + }) + record.addedNodes.forEach(node => { + if (node instanceof Element) { + this.queryAllShadowRoots(node).forEach(shadowRoot => { + this.addEntry(shadowRoot) + }) + } + }) + }) + } + + protected addEntry(shadowRoot: ShadowRoot) { + if (this.shadowRoots.includes(shadowRoot)) { + return + } + const shadowRootChildren = this.queryAllShadowRoots(shadowRoot) + shadowRootChildren.forEach(child => this.addEntry(child)) + + const [observer] = childListSubtree(shadowRoot, records => this.mutationHandler(records)) + this.entries.push({ + shadowRoot, + observer, + }) + this.dispatchEvent(new CustomEvent('shadowRootAdded', { detail: shadowRoot })) + } + + protected removeEntry(shadowRoot: ShadowRoot) { + const children = this.shadowRoots.filter(it => shadowRoot.contains(it.host)) + children.forEach(child => this.removeEntry(child)) + + const entry = this.entries.find(it => it.shadowRoot === shadowRoot) + if (entry !== undefined) { + entry.observer.disconnect() + deleteValue(this.entries, it => it === entry) + } + } + + get shadowRoots() { + return this.entries.map(entry => entry.shadowRoot) + } + + addEventListener( + type: `${ShadowRootEvents}`, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ): void { + super.addEventListener(type, callback, options) + } + + removeEventListener( + type: `${ShadowRootEvents}`, + callback: EventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean, + ): void { + super.removeEventListener(type, callback, options) + } + + forEachShadowRoot(callbacks: { + added?: (shadowRoot: ShadowRoot) => void + removed?: (shadowRoot: ShadowRoot) => void + }) { + this.shadowRoots.forEach(it => callbacks.added?.(it)) + const addedListener = (e: CustomEvent) => callbacks?.added(e.detail) + const removedListener = (e: CustomEvent) => callbacks?.removed(e.detail) + this.addEventListener(ShadowRootEvents.Added, addedListener) + this.addEventListener(ShadowRootEvents.Removed, removedListener) + return () => { + this.removeEventListener(ShadowRootEvents.Added, addedListener) + this.removeEventListener(ShadowRootEvents.Removed, removedListener) + } + } + + observe() { + if (this.observing) { + return + } + const existingRoots = this.queryAllShadowRoots() + existingRoots.forEach(root => this.addEntry(root)) + allMutations(records => this.mutationHandler(records)) + this.observing = true + } + + disconnect() { + this.entries.forEach(entry => entry.observer.disconnect()) + this.entries = [] + this.observing = false + } +} + +const shadowDomObserver = new ShadowDomObserver() + +export class ShadowDomStyles { + observer: ShadowDomObserver = shadowDomObserver + entries: ShadowRootStyleEntry[] = [] + + get shadowRoots() { + return this.entries.map(entry => entry.shadowRoot) + } + + protected addEntry(shadowRoot: ShadowRoot) { + this.entries.push({ + shadowRoot, + }) + } + + protected removeEntry(shadowRoot: ShadowRoot) { + const entry = this.entries.find(it => it.shadowRoot === shadowRoot) + if (entry !== undefined) { + deleteValue(this.entries, it => it === entry) + } + } + + addStyle(text: string) { + this.observer.observe() + const id = `shadow-dom-style-${getRandomId()}` + const element = addStyle(text, id) + const destroy = this.observer.forEachShadowRoot({ + added: async shadowRoot => { + if (this.shadowRoots.includes(shadowRoot)) { + return + } + this.addEntry(shadowRoot) + const sheet = new CSSStyleSheet() + await sheet.replace(element.innerHTML) + shadowRoot.adoptedStyleSheets.push(sheet) + }, + removed: shadowRoot => { + shadowRoot.getElementById(id)?.remove() + this.removeEntry(shadowRoot) + }, + }) + return () => { + destroy() + element.remove() + } + } +} diff --git a/src/core/utils/title.ts b/src/core/utils/title.ts index 1b4997d298..2218cda485 100644 --- a/src/core/utils/title.ts +++ b/src/core/utils/title.ts @@ -31,12 +31,24 @@ const tokenSplit = (format: string) => { } return tokens.filter(it => it !== '') } + +export const getTitleVariablesFromDate = (date: Date, padStartLength = 2) => { + return { + year: date.getFullYear().toString(), + month: (date.getMonth() + 1).toString().padStart(padStartLength, '0'), + day: date.getDate().toString().padStart(padStartLength, '0'), + hour: date.getHours().toString().padStart(padStartLength, '0'), + minute: date.getMinutes().toString().padStart(padStartLength, '0'), + second: date.getSeconds().toString().padStart(padStartLength, '0'), + millisecond: date.getMilliseconds().toString().substring(0, 3), + } +} + export const formatTitle = ( format: string, includesPageTitle = true, extraVariables: StringMap = {}, ) => { - const now = new Date() const getLegacyTitle = () => { return ( document.title @@ -55,6 +67,7 @@ export const formatTitle = ( .trim() ) } + const dateVariables = getTitleVariablesFromDate(new Date()) const builtInVariables: StringMap = { title: (() => { const videoPageTitle = dq('.video-info-container .video-title') @@ -95,14 +108,13 @@ export const formatTitle = ( bvid: unsafeWindow.bvid, cid: unsafeWindow.cid, 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 - d: now.getDate().toString().padStart(2, '0'), - h: now.getHours().toString().padStart(2, '0'), - m: now.getMinutes().toString().padStart(2, '0'), - s: now.getSeconds().toString().padStart(2, '0'), - ms: now.getMilliseconds().toString().substring(0, 3), + y: dateVariables.year, + M: dateVariables.month, + d: dateVariables.day, + h: dateVariables.hour, + m: dateVariables.minute, + s: dateVariables.second, + ms: dateVariables.millisecond, } const variables = { ...builtInVariables, diff --git a/src/ui/TextArea.vue b/src/ui/TextArea.vue index 48a9a27545..c906adb431 100644 --- a/src/ui/TextArea.vue +++ b/src/ui/TextArea.vue @@ -36,6 +36,7 @@ export default Vue.extend({ } textarea { resize: none; + field-sizing: content; width: 0; flex: 1 0 0; padding: 4px 6px;