Merge branch 'preview-features' into preview

This commit is contained in:
the1812 2024-04-23 23:02:12 +08:00
commit 87a8279ce0
47 changed files with 3456 additions and 3980 deletions

View File

@ -84,6 +84,7 @@ module.exports = {
'lines-between-class-members': 'off',
radix: ['error', 'as-needed'],
'max-classes-per-file': 'off',
'class-methods-use-this': 'off',
'prefer-destructuring': [
'error',
{

View File

@ -76,6 +76,7 @@
"preload",
"protobuf",
"pubdate",
"rcount",
"reimu",
"reloadable",
"reloadables",
@ -84,6 +85,7 @@
"reposts",
"Roadmap",
"roomid",
"rpid",
"saturationv",
"Sendbar",
"sortablejs",
@ -95,8 +97,10 @@
"Touhou",
"truetype",
"uname",
"usercard",
"userscript",
"Violentmonkey",
"vnode",
"Vuex",
"Wasm",
"watchlater",

View File

@ -1,6 +1,47 @@
<!-- spell-checker: disable -->
# 更新日志
## v2.8.10-preview
`2024-04-23`
包含 [v2.8.10](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.8.10) 的所有更新内容.
- 更新了 `动态过滤器` 的描述, 当开启 `直播信息扩充` 时不可配置 `正在直播` 的显隐. (#4705)
## v2.8.10
`2024-04-23`
<details>
<summary>获得 v2.8.9-preview 的所有新功能, 点击展开查看</summary>
- `下载视频 - WASM 混流输出` 支持更持久的缓存方式. (PR #4667 by [WakelessSloth56](https://github.com/WakelessSloth56))
- `外置稍后再看``启用快速收藏` 增加了 `显示方式` 选项, 出现无法自动适配的排版问题时可以尝试手动调整此设置. (#4532)
- 新增插件 `动态过滤器 - 移除商品带货动态`. (#4425)
> 移除动态里的商品带货动态 (UP主的推荐 · 来自 XX), 装有 `动态过滤器` 时生效.
- 新增组件 `动态图片平铺展示`. (#4645)
> 将动态中左右切换式的图片改回传统的平铺展示. (在动态详情中可能稍有延迟)
- 更换了 `自定义顶栏` 的动态提醒数字来源. (#4427)
</details>
✨新增
- 夜间模式更新对视频和直播页面的样式. (#4701, PR #4675 by [WangYK](https://github.com/AnotiaWang))
- 增强 `下载视频 - WASM 混流输出` 对错误输入流的检测, 支持 FLAC 音频流 (产生 `.mkv` 文件). (PR #4686 by [DBeidachazi](https://github.com/DBeidachazi))
- `复制动态链接` 改用 `opus` 格式的链接, 并支持新版话题页面. (PR #4695, PR #4696 by [星海](https://github.com/lovelyCARDINAL))
- 同时安装了 pakku 时, 支持下载处理后的弹幕. (PR #4712 by [xmcp](https://github.com/xmcp))
- `传统连播模式` 支持识别和处理视频列表. (#4699, PR #4713 by [Wuch](https://github.com/IEXPECTATION))
- `删除视频弹窗` 支持屏蔽收起后的弹窗. (#4703)
🐛修复
- 再次修复 `快速收起评论` 遮挡评论的表情等弹窗.
- 修复评论区相关功能失效. (#4690)
- 修复 `自定义顶栏` 导致稍后再看页面布局错位. (#4668)
☕开发者相关
- Comment API 现已重构并支持 Vue 3 版本的评论区. (#4690)
## v2.8.9-preview
`2024-03-09`

View File

@ -29,6 +29,15 @@ https://afdian.net/@the1812?tab=sponsor
| 时间 | 用户名 | 单号后4位 | 金额 |
| ------------------- | --------------------- | --------- | ------- |
| 2024.04.08 16:02:38 | 匿名 | 2455 | ¥5.00 |
| 2024.03.31 16:36:20 | N*r | 3391 | ¥5.20 |
| 2024.03.27 21:25:21 | M*g | 0180 | ¥10.00 |
| 2024.03.23 08:36:29 | 匿名 | 3363 | ¥7.00 |
| 2024.03.23 04:28:31 | 智木人 | 2847 | ¥2.33 |
| 2024.03.21 22:06:40 | X*r | 0185 | ¥5.00 |
| 2024.03.20 19:34:30 | 匿名 | 5133 | ¥52.10 |
| 2024.03.12 19:16:12 | 匿名 | 0321 | ¥20.00 |
| 2024.03.09 21:40:26 | 匿名 | 1963 | ¥2.33 |
| 2024.03.01 16:12:34 | 5*B | 0619 | ¥2.33 |
| 2024.02.29 08:53:14 | M*z | 8723 | ¥2.33 |
| 2024.02.20 14:47:10 | 匿名 | 4111 | ¥5.00 |

View File

@ -27,7 +27,7 @@
"type": "component",
"name": "feedsFilter",
"displayName": "动态过滤器",
"description": "按照类型或者关键词过滤动态首页的内容, 也可以移除动态页的一些侧边卡片. 详细设置请前往[动态首页](https://t.bilibili.com/)查看.\r\n",
"description": "按照类型或者关键词过滤动态首页的内容, 也可以移除动态页的一些侧边卡片. 详细设置请前往[动态首页](https://t.bilibili.com/)查看.\r\n\r\n> 侧边卡片的 `正在直播` 指原版的板块, 如果你使用了 `直播信息扩充`, 则需要关闭该功能才能生效.\r\n",
"fullRelativePath": "../../registry/dist/components/feeds/filter.js",
"fullAbsolutePath": "registry/dist/components/feeds/filter.js"
},
@ -835,7 +835,7 @@
"type": "component",
"name": "removePlayerPopup",
"displayName": "删除视频弹窗",
"description": "删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择.\r\n",
"description": "删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择. 如果之前点了收起弹窗, 则收起后的小弹窗则会直接删除. (不受类别选择影响)\r\n",
"fullRelativePath": "../../registry/dist/components/video/player/remove-popup.js",
"fullAbsolutePath": "registry/dist/components/video/player/remove-popup.js"
},

View File

@ -38,6 +38,8 @@
按照类型或者关键词过滤动态首页的内容, 也可以移除动态页的一些侧边卡片. 详细设置请前往[动态首页](https://t.bilibili.com/)查看.
> 侧边卡片的 `正在直播` 指原版的板块, 如果你使用了 `直播信息扩充`, 则需要关闭该功能才能生效.
### [强制固定动态侧栏](../../registry/dist/components/feeds/fixed-sidebars.js)
`fixedFeedsSidebars`
@ -1158,7 +1160,7 @@ by [@JLoeve](https://github.com/LonelySteve)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/player/remove-popup.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/player/remove-popup.js)
删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择.
删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择. 如果之前点了收起弹窗, 则收起后的小弹窗则会直接删除. (不受类别选择影响)
### [启用视频截图](../../registry/dist/components/video/player/screenshot.js)
`videoScreenshot`

View File

@ -37,7 +37,7 @@
"type": "component",
"name": "removePlayerPopup",
"displayName": "删除视频弹窗",
"description": "删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择.\r\n",
"description": "删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择. 如果之前点了收起弹窗, 则收起后的小弹窗则会直接删除. (不受类别选择影响)\r\n",
"fullRelativePath": "../../registry/dist/components/video/player/remove-popup.js",
"fullAbsolutePath": "registry/dist/components/video/player/remove-popup.js"
},

View File

@ -82,7 +82,7 @@
},
"pnpm": {
"overrides": {
"caniuse-lite": "1.0.30001547",
"caniuse-lite": "1.0.30001609",
"consolidate": "1.0.1"
}
},

6135
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,9 @@ const sideCards: { [id: number]: SideCardType } = {
displayName: '发布动态',
},
}
if (getComponentSettings('extendFeedsLive').enabled) {
delete sideCards[3]
}
let cardsManager: typeof import('@/components/feeds/api').feedsCardsManager
const sideBlock = 'feeds-filter-side-block-'

View File

@ -1 +1,3 @@
按照类型或者关键词过滤动态首页的内容, 也可以移除动态页的一些侧边卡片. 详细设置请前往[动态首页](https://t.bilibili.com/)查看.
> 侧边卡片的 `正在直播` 指原版的板块, 如果你使用了 `直播信息扩充`, 则需要关闭该功能才能生效.

View File

@ -36,6 +36,7 @@
}
.at-panel,
.emoji-panel,
.reply-box .box-expand,
.reply-operation .operation-list {
z-index: 111 !important;
}

View File

@ -13,7 +13,12 @@ export const component = defineComponentMetadata({
const vueData = getVueData(card.element)
// 普通动态
const path = 'data.modules.module_dynamic.major.opus.style'
const cardType = vueData?.data?.type
const path =
cardType === 'DYNAMIC_TYPE_FORWARD'
? 'data.orig.modules.module_dynamic.major.opus.style'
: 'data.modules.module_dynamic.major.opus.style'
const imageViewerStyle: number | null = lodash.get(vueData, path, null)
if (imageViewerStyle === 1) {
lodash.set(vueData, path, undefined)

View File

@ -33,7 +33,7 @@ li.nav-item[report-id="playpage_dynamic"] .i-frame,
}
#message-navbar,
body:not(.history-page) #biliMainHeader:not(.z-top-container),
.z-top-container:not(#biliMainHeader) {
.z-top-container:not(#biliMainHeader,.has-menu) {
max-height: var(--navbar-height) !important;
}
body.history-page #biliMainHeader:not(.z-top-container) {

View File

@ -245,11 +245,11 @@ blockquote {
&,
&:link,
&:visited {
@include color('a');
@include color('e');
}
}
.live-skin-normal-text {
@include color('a');
@include color('e');
}
.live-skin-main-text,
.live-skin-highlight-text {

View File

@ -940,6 +940,9 @@ html {
@include set-theme-color('border-bottom-color');
@include theme-color();
}
.underline {
@include theme-border-color();
}
}
.name {
@include set-color('border-bottom-color', 'a');

View File

@ -344,6 +344,11 @@
@include color('a');
}
}
.gift-item-asset {
.b-img {
@include background-color();
}
}
}
.gift-sender-panel {
.content {
@ -374,6 +379,9 @@
}
.right-arrow {
@include border-color('e');
path {
@include fill('e');
}
}
}
}
@ -383,6 +391,9 @@
@include background-color('3');
.arrow {
@include border-color('e');
path {
@include fill('e');
}
}
}
}
@ -401,6 +412,9 @@
}
.bag-icon {
@include border-color('e');
path {
@include fill('e');
}
}
&:hover {
@include background-color('8884');
@ -708,9 +722,7 @@
@include set-color('border-top-color', '4');
}
#{contains('mediainfo_mediaRight')} {
#{contains('mediainfo_media_desc_section')}
#{contains('mediainfo_display_area')}
#{contains('mediainfo_ellipsis')} {
#{contains('mediainfo_media_desc_section')} #{contains('mediainfo_display_area')} #{contains('mediainfo_ellipsis')} {
background: linear-gradient(90deg, hsla(0, 0%, 100%, 0), #222 20%, #222);
@include theme-color();
}
@ -881,3 +893,67 @@
}
}
}
.left-part-ctnr {
.gift-left-part > [class$='-text'] {
@include color('e');
}
.pointer.arrow-box {
@include set-color('--btnBg', '5');
@include set-color('--btnHoverBg', '6');
.arrow path {
@include fill('e');
}
}
.inner-part {
@include set-color('--wrapBg', '4');
}
}
.static-feed-number-list {
.feed-button-root .content-box {
@include color('e');
}
}
.feed-button-box .custom-feed-input {
@include color('e');
}
.gift-control-panel {
.gift-panel-box {
@include background-color();
}
}
.gift-panel-container-root {
@include set-color('--gift-panel-container_background-color', '4');
@include set-color('--gift-panel-container_container-border-color', '5');
@include set-color('--gift-panel-container_feed-user-info-text-color', 'e');
}
.left-container {
.new-tabs {
@include background-color('4');
@include border-color();
}
}
.card-box .switch-btn img {
filter: invert(0.8) !important;
}
.package-gift-root {
.des-box {
@include color('a');
.switch {
@include set-color('--toggle-switch-bg-color', '3');
&.activated {
@include set-theme-color('--toggle-switch-bg-color');
}
}
}
.new-silver-wallet {
@include color('e');
@include background-color('3');
}
}
.shortcuts-ctnr {
.shortcut-item {
.item-icon {
@include to-white();
}
}
}

View File

@ -368,7 +368,6 @@ button.submit-button.disabled,
.rank-list-ctnr .tabs,
.player-and-aside-area .aside-area,
.player-and-aside-area .aside-area .rank-list-section,
.gift-presets .gift-panel-box,
.bilibili-live-player-ending-panel-recommend-card-pic,
.rank-list-ctnr .own,
.rank-cntr .item.own,

View File

@ -13,6 +13,8 @@ const getIpLocation = (element: HTMLElement) => {
let version = 2
let bbComment: any
const marginLeft = 15
// 带IP属地显示的评论创建
const createListCon = function listCon(item, i, pos) {
const blCon = this._parentBlacklistDom(item, i, pos)
@ -29,7 +31,9 @@ const createListCon = function listCon(item, i, pos) {
'<span class="time-location">',
'<span class="reply-time">'.concat(this._formateTime(item.ctime), '</span>'),
item?.reply_control?.location
? `<span class="reply-location">${item?.reply_control?.location || ''}</span>`
? `<span class="reply-location" style="margin-left:${marginLeft}px;">${
item?.reply_control?.location || ''
}</span>`
: '',
'</span>',
item.lottery_id
@ -90,7 +94,9 @@ const createSubReplyItem = function subReply(item, i) {
'<span class="time-location">',
'<span class="reply-time">'.concat(this._formateTime(item.ctime), '</span>'),
item?.reply_control?.location
? `<span class="reply-location">${item?.reply_control?.location || ''}</span>`
? `<span class="reply-location" style="margin-left:${marginLeft}px;">${
item?.reply_control?.location || ''
}</span>`
: '',
'</span>',
`<span class="like ${item.action === 1 ? 'liked' : ''}"><i></i><span>${
@ -135,7 +141,7 @@ const blackroomCreateListCon = function listCon(e, n, t) {
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>`
? `<span style="margin-left:${marginLeft - 15}px;">${e?.reply_control?.location || ''}</span>`
: '',
e.lottery_id
? ''
@ -197,7 +203,7 @@ const blackroomCreateSubReplyItem = function subReply(e, n) {
'<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 style="margin-left:${marginLeft - 15}px;">${e?.reply_control?.location || ''}</span>`
: '',
`<span class="like ${1 === e.action ? 'liked' : ''}"><i></i><span>${
e.like || ''
@ -269,6 +275,7 @@ observer.observe(document.head, { childList: true })
const processItems = (items: CommentReplyItem[]) => {
items.forEach(item => {
const location = getIpLocation(item.element)
console.log(`location: ${location}`)
if (location !== undefined) {
const replyTime =
item.element.querySelector('.reply-info>.reply-time') ??
@ -276,7 +283,7 @@ const processItems = (items: CommentReplyItem[]) => {
if (replyTime.childElementCount === 0) {
// 避免在评论更新的情况下重复添加
const replyLocation = document.createElement('span')
replyLocation.style.marginLeft = '5px'
replyLocation.style.marginLeft = `${marginLeft}px`
replyLocation.innerText = location
replyTime.appendChild(replyLocation)
}

View File

@ -286,9 +286,9 @@ const decode = lodash.curry(async (type: string, blob: Blob) => {
export const decodeDanmakuSegment = decode('DmSegMobileReply')
export const decodeDanmakuView = decode('DmWebViewReply')
// 这里为了兼容 pakku, 只能用 fetch https://github.com/xmcp/pakku.js/issues/153
// 同时安装了 pakku 时unsafeWindow.fetch 将调用 hook 过的 fetch 来获取修改后的弹幕
const fetchBlob = async (url: string) => {
const response = await fetch(url, { mode: 'cors', credentials: 'include' })
const response = await unsafeWindow.fetch(url, { mode: 'cors', credentials: 'include' })
return response.blob()
}
export const getDanmakuView = async (aid: string | number, cid: string | number) => {

View File

@ -307,7 +307,6 @@ export class ExtendSpeedComponent extends EntrySpeedComponent<Options> {
}
}
// eslint-disable-next-line class-methods-use-this
getSpeedContextMixin({ menuListElement }: SpeedContext): Partial<SpeedContext> {
return {
query: (speed: number) =>
@ -444,7 +443,6 @@ export class ExtendSpeedComponent extends EntrySpeedComponent<Options> {
setTimeout(() => this.forceUpdateStyle(value))
}
// eslint-disable-next-line class-methods-use-this
protected readonly filterNativeSpeed =
() =>
({ subscribe, next }: PublishContext<number>) => {

View File

@ -1,4 +1,5 @@
import { defineComponentMetadata } from '@/components/define'
import { getVueData } from '@/components/feeds/api'
import { playerAgent } from '@/components/video/player-agent'
import { childListSubtree, videoChange } from '@/core/observer'
import { select } from '@/core/spin-query'
@ -44,9 +45,50 @@ export const component = defineComponentMetadata({
),
),
]
await playerReady()
// 初始化状态
const rightPanel = await Promise.any([
select('.right-container-inner'),
select('.playlist-container--right'),
])
// 如果未找到元素,提前退出不执行后续动作
if (!rightPanel) {
console.warn('未找到 rightPanelContainer 或 playListContainer')
return
}
const checkPlayListPlayMode = async () => {
// 检查是不是播放列表中的最后一个视频或者最后一个分 P 视频的最后一个视频
const videoSequentialNumber = dq('.list-count')
const sequentialNumbers = videoSequentialNumber.innerHTML.split('/')
// 检查最后一个元素是单个视频还是多 P 视频
const lastVedioElement = dq(
'.action-list .action-list-inner .action-list-item-wrap:last-child .action-list-item .actionlist-item-inner',
)
let isLastVideo = false
if (lastVedioElement.classList.contains('singlep-list-item-inner')) {
isLastVideo = lastVedioElement.classList.contains('siglep-active')
} else {
isLastVideo =
lastVedioElement.children[1].lastElementChild.classList.contains(
'multip-list-item-active',
)
}
const shouldContinue = !(sequentialNumbers[0] >= sequentialNumbers[1] && isLastVideo)
const app = document.getElementById('app')
const vueInstance = getVueData(app)
// 不用判断当前状态是什么,直接将将是否需要继续播放赋值 vue 实例中的 continuousPlay
vueInstance.setContinuousPlay(shouldContinue)
}
const isChecked = (container: HTMLElement) =>
Boolean(container.querySelector('.switch-button.on') || container.matches(':checked'))
await playerReady()
const checkPlayMode = async () => {
const element = (await select(
[...autoPlayControls.disable, ...autoPlayControls.enable].join(','),
@ -62,16 +104,19 @@ export const component = defineComponentMetadata({
element.click()
}
}
videoChange(async () => {
checkPlayMode()
const video = (await playerAgent.query.video.element()) as HTMLVideoElement
video?.addEventListener('play', checkPlayMode, { once: true })
})
const rightPanelContainer = await select('.right-container-inner')
if (!rightPanelContainer) {
console.warn('未找到 rightPanelContainer')
return
const checkRightPanelPlayMode = async () => {
rightPanel.classList.contains('right-container-inner')
? checkPlayMode()
: checkPlayListPlayMode()
}
childListSubtree(rightPanelContainer, () => checkPlayMode())
videoChange(async () => {
checkRightPanelPlayMode()
const video = (await playerAgent.query.video.element()) as HTMLVideoElement
video?.addEventListener('play', checkRightPanelPlayMode, { once: true })
})
childListSubtree(rightPanel, () => checkRightPanelPlayMode())
},
})

View File

@ -1 +1 @@
删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择.
删除视频播放器中出现的各种弹窗, 类别可在选项中分别选择. 如果之前点了收起弹窗, 则收起后的小弹窗则会直接删除. (不受类别选择影响)

View File

@ -8,6 +8,13 @@ export const component = defineComponentMetadata({
entry: ({ settings, metadata }) => {
const { options } = settings
const { kebabCase } = lodash
addComponentListener(
metadata.name,
(enabled: boolean) => {
document.body.classList.toggle(kebabCase(metadata.name), enabled)
},
true,
)
Object.keys(options).forEach(optionName => {
addComponentListener(
`${metadata.name}.${optionName}`,

View File

@ -1,5 +1,9 @@
body {
&.remove-player-popup {
// https://www.bilibili.com/video/BV1WA4m1L7xa/
.bili-cmd-shrink {
display: none !important;
}
// https://www.bilibili.com/video/BV1V54y1b7yW/
&-combo-likes {
.bili-guide,

View File

@ -1,8 +1,7 @@
import { defineComponentMetadata } from '@/components/define'
import { childList, urlChange } from '@/core/observer'
import { playerReady } from '@/core/utils'
import { playerReady, getVue2Data } from '@/core/utils'
import { videoUrls } from '@/core/utils/urls'
import { getVueData } from '@/components/feeds/api'
import { VideoInfo } from '@/components/video/video-info'
import { useScopedConsole } from '@/core/utils/log'
import { addComponentListener, getComponentSettings } from '@/core/settings'
@ -147,7 +146,7 @@ export const component = defineComponentMetadata({
const getRecoList = () => {
const reco_list = dq('#reco_list')
let recoList: RecommendList = getVueData(reco_list)
let recoList: RecommendList = getVue2Data(reco_list)
if (recoList.isOpen === undefined) {
recoList = recoList.$children[0]
if (recoList.isOpen === undefined) {

View File

@ -4,10 +4,11 @@ export const plugin: PluginMetadata = {
name: 'feedsFilter.pluginBlocks.chargeFeeds',
displayName: '动态过滤器 - 移除充电专属动态',
async setup() {
const { forEachFeedsCard, getVueData } = await import('@/components/feeds/api')
const { getVue2Data } = await import('@/core/utils')
const { forEachFeedsCard } = await import('@/components/feeds/api')
forEachFeedsCard({
added: card => {
const vueData = getVueData(card.element)
const vueData = getVue2Data(card.element)
const blockedType: number | null = lodash.get(
vueData,
'data.modules.module_dynamic.major.blocked.blocked_type',

View File

@ -34,21 +34,40 @@ async function load(toast: Toast) {
),
})
}
export async function run(name: string, videoUrl: string, audioUrl: string, toast: Toast) {
export async function run(
name: string,
toast: Toast,
videoUrl: string,
audioUrl: string,
isFlac: boolean,
) {
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 outputExt = isFlac ? 'mkv' : 'mp4'
name = isFlac ? name.replace(/.[^/.]+$/, `.${outputExt}`) : name
await ffmpeg.exec([
'-i',
'video',
'-i',
'audio',
'-c:v',
'copy',
'-c:a',
isFlac ? 'flac' : 'copy',
'-f',
isFlac ? 'matroska' : 'mp4',
`output.${outputExt}`,
])
const output = await ffmpeg.readFile('output.mp4')
const output = await ffmpeg.readFile(`output.${outputExt}`)
const outputBlob = new Blob([output], {
type: 'video/mp4',
type: isFlac ? 'video/x-matroska' : 'video/mp4',
})
toast.message = '完成!'

View File

@ -1,7 +1,9 @@
import { Toast } from '@/core/toast'
import { Toast, ToastType } from '@/core/toast'
import { PluginMetadata } from '@/plugins/plugin'
import { DownloadVideoOutput } from '../../../../components/video/download/types'
import { run } from './handler'
import { getComponentSettings } from '@/core/settings'
import type { Options } from '../../../../components/video/download'
const title = 'WASM 混流输出'
const desc = '使用 WASM 在浏览器中下载并合并音视频'
@ -22,12 +24,35 @@ export const plugin: PluginMetadata = {
description: `${desc},运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
runAction: async action => {
const fragments = action.infos.flatMap(it => it.titledFragments)
if (fragments.length !== 2) {
Toast.error('仅支持 DASH 格式', title)
const { dashAudioExtension, dashFlacAudioExtension, dashVideoExtension } =
getComponentSettings<Options>('downloadVideo').options
if (
fragments.length > 2 ||
(fragments.length === 2 &&
!(
fragments[0].extension === dashVideoExtension &&
(fragments[1].extension === dashAudioExtension ||
fragments[1].extension === dashFlacAudioExtension)
))
) {
Toast.error('仅支持单个视频下载', title).duration = 1500
} else {
const toast = Toast.info('正在加载', title)
try {
await run(fragments[0].title, fragments[0].url, fragments[1].url, toast)
if (fragments.length === 2) {
await run(
fragments[0].title,
toast,
fragments[0].url,
fragments[1].url,
fragments[1].extension === dashFlacAudioExtension,
) // [dash]
} else {
toast.type = ToastType.Error
toast.message = '仅支持 dash 格式视频下载'
toast.duration = 1500
}
} catch (error) {
toast.close()
Toast.error(String(error), title)

View File

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

View File

@ -5,9 +5,8 @@ import { ListAdaptorKey, FeedsCardsListAdaptor } from './adaptor'
export const feedsCardCallbacks: Required<FeedsCardCallback>[] = []
export const getVueData = (el: any) =>
// eslint-disable-next-line no-underscore-dangle
el.__vue__ ?? el.parentElement.__vue__ ?? el.children[0].__vue__
// TODO: 仅做兼容性导出, v2.8.11 之后可移除
export { getVue2Data as getVueData } from '@/core/utils'
export const createNodeValidator =
(className: string) =>

View File

@ -1,6 +1,7 @@
import { childList } from '@/core/observer'
import { getVue2Data } from '@/core/utils'
import { sq } from '@/core/spin-query'
import { FeedsCardsManager, FeedsCardsManagerEventType, getVueData } from './base'
import { FeedsCardsManager, FeedsCardsManagerEventType } from './base'
import { feedsCardTypes, isRepostType, FeedsCard, FeedsCardType } from '../types'
const getFeedsCardType = (element: HTMLElement) => {
@ -85,7 +86,7 @@ const parseCard = async (element: HTMLElement): Promise<FeedsCard> => {
}
const el = await sq(
() => element,
(it: any) => Boolean(getVueData(it) || !element.parentNode),
(it: any) => Boolean(getVue2Data(it) || !element.parentNode),
{ queryInterval: 100 },
)
if (element.parentNode === null) {
@ -93,10 +94,10 @@ const parseCard = async (element: HTMLElement): Promise<FeedsCard> => {
return ''
}
if (el === null) {
console.warn(el, element, getVueData(el), element.parentNode)
console.warn(el, element, getVue2Data(el), element.parentNode)
return ''
}
const vueData = getVueData(el)
const vueData = getVue2Data(el)
if (type === feedsCardTypes.repost) {
const currentText = vueData.card.item.content
const repostData = getRepostData(vueData)
@ -135,7 +136,7 @@ const parseCard = async (element: HTMLElement): Promise<FeedsCard> => {
element.setAttribute('data-type', card.type.id.toString())
if (isRepostType(card)) {
const currentUsername = card.username
const vueData = getVueData(card.element)
const vueData = getVue2Data(card.element)
const repostUsername = lodash.get(vueData, 'card.origin_user.info.uname', '')
if (currentUsername === repostUsername) {
element.setAttribute('data-self-repost', 'true')

View File

@ -1,12 +1,7 @@
import { childList } from '@/core/observer'
import { descendingStringSort } from '@/core/utils/sort'
import { pascalCase } from '@/core/utils'
import {
createNodeValidator,
FeedsCardsManager,
FeedsCardsManagerEventType,
getVueData,
} from './base'
import { pascalCase, getVue2Data } from '@/core/utils'
import { createNodeValidator, FeedsCardsManager, FeedsCardsManagerEventType } from './base'
import { FeedsCard, FeedsCardType, feedsCardTypes, isRepostType } from '../types'
import { selectAll } from '@/core/spin-query'
@ -78,7 +73,7 @@ const getText = (dynamicModule: any, cardType: FeedsCardType) => {
return combineText(mainText, typeText)
}
const parseCard = async (element: HTMLElement): Promise<FeedsCard> => {
const vueData = getVueData(element)
const vueData = getVue2Data(element)
const parseModules = (rawModules: any) => {
if (Array.isArray(rawModules)) {
return Object.fromEntries(
@ -157,7 +152,7 @@ export class FeedsCardsManagerV2 extends FeedsCardsManager {
if (!isNodeValid(node)) {
return
}
const vueData = getVueData(node)
const vueData = getVue2Data(node)
if (!vueData) {
return
}

View File

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

View File

@ -1,307 +1,25 @@
import { dqa } from '@/core/utils'
import { allMutations, childList, childListSubtree } from '@/core/observer'
import { contentLoaded } from '@/core/life-cycle'
import { CommentAreaManager } from './comment/comment-area-manager'
import { CommentAreaCallback, CommentItemCallback } from './comment/types'
import { CommentReplyItem } from './comment/reply-item'
/** 表示一条评论回复 */
export class CommentReplyItem extends EventTarget {
/** 对应元素 */
element: HTMLElement
/** 评论ID */
id: string
/** 评论者UID */
userId: string
/** 用户名 */
userName: string
/** 评论内容 */
content: string
/** (仅 v1) 评论时间 (文本) */
timeText?: string
/** (仅 v2) 评论时间戳 */
time?: number
/** 点赞数 */
likes: number
constructor(initParams: Omit<CommentReplyItem, keyof EventTarget>) {
super()
this.element = initParams.element
this.id = initParams.id
this.userId = initParams.userId
this.userName = initParams.userName
this.content = initParams.content
this.timeText = initParams.timeText
this.time = initParams.time
this.likes = initParams.likes
}
}
/** 评论更新的事件类型 */
export const RepliesUpdateEventType = 'repliesUpdate'
/** 评论更新的事件回调类型 */
export type RepliesUpdateEventCallback = (event: CustomEvent<CommentReplyItem[]>) => void
const createRepliesUpdateEvent = (replies: CommentReplyItem[]) => {
return new CustomEvent(RepliesUpdateEventType, {
detail: replies,
})
}
/** 表示一条评论 */
export class CommentItem extends CommentReplyItem {
/** 评论图片 */
pictures?: string[]
/** 回复 */
replies: CommentReplyItem[]
constructor(initParams: Omit<CommentItem, keyof EventTarget | 'dispatchRepliesUpdate'>) {
super(initParams)
this.pictures = initParams.pictures
this.replies = initParams.replies
}
addEventListener(
type: typeof RepliesUpdateEventType,
callback: RepliesUpdateEventCallback | null,
options?: EventListenerOptions | boolean,
) {
super.addEventListener(type, callback, options)
}
removeEventListener(
type: typeof RepliesUpdateEventType,
callback: RepliesUpdateEventCallback | null,
options?: EventListenerOptions | boolean,
) {
super.removeEventListener(type, callback, options)
}
/** 触发评论更新事件 */
dispatchRepliesUpdate(replies: CommentReplyItem[]) {
return super.dispatchEvent(createRepliesUpdateEvent(replies))
}
}
export type CommentItemCallback = (item: CommentItem) => void
/** 表示一个评论区 */
export interface CommentArea {
/** 对应元素 */
element: HTMLElement
/** 评论列表 */
items: CommentItem[]
/** 与之关联的 MutationObserver */
observer?: MutationObserver
}
/** 当前页面所有的评论区列表 */
export const commentAreas: CommentArea[] = []
export type CommentAreaCallback = (area: CommentArea) => void
const commentAreaClasses = ['bili-comment', 'bb-comment']
const replyItemClasses = ['list-item.reply-wrap', 'reply-item']
const itemAddedCallbacks: CommentItemCallback[] = []
const itemRemovedCallbacks: CommentItemCallback[] = []
const commentAreaCallbacks: CommentAreaCallback[] = []
/** (v2) 获取 Vue 数据 (评论 / 回复) */
const getVueData = (element: HTMLElement) => {
// eslint-disable-next-line no-underscore-dangle
const props = (element as any).__vueParentComponent?.props
return props?.reply ?? props?.subReply
}
/** (v2) 获取回复对应的元素 */
const getReplyItemElement = (parent: HTMLElement, replyId: string) => {
const [replyElement] = dqa(parent, '.sub-reply-item').filter(
(it: HTMLElement) => getVueData(it).rpid_str === replyId,
)
return replyElement as HTMLElement
}
/** (v1 / v2) 获取评论 ID */
const getCommentId = (element: HTMLElement) => {
const attributeId = element.getAttribute('data-id')
if (attributeId) {
return attributeId
}
const vueData = getVueData(element)
return vueData?.rpid_str ?? ''
}
/** (v2) 解析评论对象 */
const parseCommentItemV2 = (element: HTMLElement) => {
const vueData = getVueData(element)
if (!vueData) {
throw new Error('Invalid comment item')
}
const parseReplies = () => {
if (!vueData.replies) {
return []
}
return vueData.replies.map((r: any): CommentReplyItem => {
return new CommentReplyItem({
id: r.rpid_str,
element: getReplyItemElement(element, r.rpid_str),
userId: r.member.mid,
userName: r.member.uname,
content: r.content.message,
time: r.ctime * 1000,
likes: r.like,
})
})
}
const item = new CommentItem({
id: vueData.rpid_str,
element,
userId: vueData.member.mid,
userName: vueData.member.uname,
content: vueData.content.message,
time: vueData.ctime * 1000,
likes: vueData.like,
pictures: vueData.content?.pictures?.map(img => {
return img.img_src
}),
replies: parseReplies(),
})
if (item.replies.length < vueData.rcount) {
const replyBox = dq(element, '.sub-reply-list')
childList(replyBox, records => {
item.replies = parseReplies()
if (records.length !== 0) {
item.dispatchRepliesUpdate(item.replies)
}
})
}
return item
}
/** (v1 / v2) 解析评论对象 */
const parseCommentItem = (element: HTMLElement) => {
const user = element.querySelector('.con .user .name') as HTMLElement
if (!user) {
return parseCommentItemV2(element)
}
const parseReplyItem = (replyElement: HTMLElement) => {
const replyFace = replyElement.querySelector('.reply-face') as HTMLElement
const replyUser = replyElement.querySelector('.reply-con .user .name') as HTMLElement
return new CommentReplyItem({
id: replyElement.getAttribute('data-id'),
element: replyElement,
userId: replyFace.getAttribute('data-usercard-mid'),
userName: replyUser.textContent,
content: replyElement.querySelector('.text-con').textContent,
timeText: replyElement.querySelector('.info .time, .info .time-location').textContent,
likes: parseInt(replyElement.querySelector('.info .like span').textContent),
})
}
const item = new CommentItem({
id: element.getAttribute('data-id'),
element,
userId: user.getAttribute('data-usercard-mid'),
userName: user.textContent,
content: element.querySelector('.con .text').textContent,
timeText: element.querySelector('.con .info .time, .info .time-location').textContent,
likes: parseInt(element.querySelector('.con .like span').textContent),
replies: [],
})
if (dq(element, '.reply-box .view-more')) {
const replyBox = dq(element, '.reply-box') as HTMLElement
childList(replyBox, records => {
item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
if (records.length !== 0) {
item.dispatchRepliesUpdate(item.replies)
}
})
} else {
item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
}
return item
}
const observeItems = (area: CommentArea) => {
if (area.observer) {
return
}
const replyItemSelector = replyItemClasses.map(c => `.${c}`).join(',')
area.items = dqa(area.element, replyItemSelector).map(parseCommentItem)
area.items.forEach(item => {
itemAddedCallbacks.forEach(c => c(item))
})
;[area.observer] = childListSubtree(area.element, records => {
records.forEach(r => {
const isCommentItem = (n: Node): n is HTMLElement =>
n instanceof HTMLElement && n.matches(replyItemSelector)
r.addedNodes.forEach(n => {
if (isCommentItem(n)) {
const commentItem = parseCommentItem(n)
area.items.push(commentItem)
itemAddedCallbacks.forEach(c => c(commentItem))
}
})
r.removedNodes.forEach(n => {
if (isCommentItem(n)) {
const id = getCommentId(n)
const index = area.items.findIndex(item => item.id === id)
if (index !== -1) {
const [commentItem] = area.items.splice(index, 1)
itemRemovedCallbacks.forEach(c => c(commentItem))
}
}
})
})
})
}
const observeAreas = (node: Node) => {
if (node instanceof HTMLElement && commentAreaClasses.some(c => node.classList.contains(c))) {
const area: CommentArea = {
element: node,
items: [],
}
commentAreas.push(area)
observeItems(area)
commentAreaCallbacks.forEach(c => c(area))
}
}
const init = () => {
allMutations(records => {
records.forEach(r => {
r.addedNodes.forEach(n => observeAreas(n))
})
})
dqa(commentAreaClasses.map(c => `.${c}`).join(',')).forEach(observeAreas)
}
contentLoaded(init)
export const commentAreaManager = new CommentAreaManager()
contentLoaded(() => {
commentAreaManager.init()
})
/** 为每一个评论区执行操作 */
export const forEachCommentArea = (callback: CommentAreaCallback) => {
commentAreas.forEach(it => callback(it))
commentAreaCallbacks.push(callback)
commentAreaManager.forEachCommentArea(callback)
}
/** 为每一条评论执行操作 (不包含评论的回复) */
export const forEachCommentItem = (callbacks: {
added?: CommentItemCallback
removed?: CommentItemCallback
}) => {
const { added, removed } = callbacks
forEachCommentArea(area => {
if (added) {
area.items.forEach(item => added(item))
itemAddedCallbacks.push(added)
}
if (removed) {
itemRemovedCallbacks.push(removed)
}
})
commentAreaManager.forEachCommentItem(callbacks)
}
const addMenuItemV2 = (...params: Parameters<typeof addMenuItem>) => {
const [item, config] = params
const operationList = dq(item.element, '.operation-list') as HTMLUListElement
const { className, text, action } = config
if (!operationList || dq(operationList, `.${className}`)) {
return
}
const menuItem = document.createElement('li')
menuItem.classList.add(className, 'operation-option')
menuItem.innerHTML = `<span class="option-title">${text}</span>`
menuItem.addEventListener('click', event => {
action(event)
operationList.style.display = 'none'
})
operationList.appendChild(menuItem)
}
/**
*
* @param item
@ -315,21 +33,5 @@ export const addMenuItem = (
action: (e: MouseEvent) => void
},
) => {
const operationList = dq(item.element, '.opera-list ul') as HTMLUListElement
if (!operationList) {
addMenuItemV2(item, config)
return
}
const { className, text, action } = config
if (dq(operationList, `.${className}`)) {
return
}
const menuItem = document.createElement('li')
menuItem.classList.add(className)
menuItem.textContent = text
menuItem.addEventListener('click', event => {
action(event)
operationList.style.display = 'none'
})
operationList.appendChild(menuItem)
commentAreaManager.forEachCommentArea(area => area.addMenuItem(item, config))
}

View File

@ -0,0 +1,111 @@
import { childListSubtree } from '@/core/observer'
import type { CommentItem } from '../comment-item'
import type { CommentItemCallback } from '../types'
import type { CommentReplyItem } from '../reply-item'
import { getRandomId } from '@/core/utils'
/** 表示一个评论区 */
export abstract class CommentArea {
/** 对应元素 */
element: HTMLElement
/** 评论列表 */
items: CommentItem[] = []
/** 与之关联的 MutationObserver */
protected observer?: MutationObserver
protected itemAddedCallbacks: CommentItemCallback[] = []
protected itemRemovedCallbacks: CommentItemCallback[] = []
protected static replyItemClasses = ['list-item.reply-wrap', 'reply-item']
protected static replyItemSelector = CommentArea.replyItemClasses.map(c => `.${c}`).join(',')
constructor(element: HTMLElement) {
this.element = element
}
abstract parseCommentItem(element: HTMLElement): CommentItem
abstract getCommentId(element: HTMLElement): string
abstract addMenuItem(
item: CommentReplyItem,
config: {
className: string
text: string
action: (e: MouseEvent) => void
},
): void
protected isCommentItem(n: Node): n is HTMLElement {
return n instanceof HTMLElement && n.matches(CommentArea.replyItemSelector)
}
/** 在每一轮 CommentItem 解析前调用 */
protected beforeParse(elements: HTMLElement[]) {
return lodash.noop(elements)
}
observeItems() {
if (this.observer) {
return
}
performance.mark('observeItems start')
const elements = dqa(this.element, CommentArea.replyItemSelector) as HTMLElement[]
if (elements.length > 0) {
this.beforeParse(elements)
this.items = elements.map(it => this.parseCommentItem(it as HTMLElement))
}
this.items.forEach(item => {
this.itemAddedCallbacks.forEach(c => c(item))
})
;[this.observer] = childListSubtree(this.element, records => {
const observerCallId = getRandomId()
performance.mark(`observeItems subtree start ${observerCallId}`)
const addedCommentElements: HTMLElement[] = []
const removedCommentElements: HTMLElement[] = []
records.forEach(r => {
r.addedNodes.forEach(n => {
if (this.isCommentItem(n)) {
addedCommentElements.push(n)
}
})
r.removedNodes.forEach(n => {
if (this.isCommentItem(n)) {
removedCommentElements.push(n)
}
})
})
if (addedCommentElements.length > 0) {
this.beforeParse(addedCommentElements)
}
addedCommentElements.forEach(n => {
const commentItem = this.parseCommentItem(n)
this.items.push(commentItem)
this.itemAddedCallbacks.forEach(c => c(commentItem))
})
removedCommentElements.forEach(n => {
const id = this.getCommentId(n)
const index = this.items.findIndex(item => item.id === id)
if (index !== -1) {
const [commentItem] = this.items.splice(index, 1)
this.itemRemovedCallbacks.forEach(c => c(commentItem))
}
})
performance.mark(`observeItems subtree end ${observerCallId}`)
performance.measure(
`observeItems subtree ${observerCallId}`,
`observeItems subtree start ${observerCallId}`,
`observeItems subtree end ${observerCallId}`,
)
})
performance.mark('observeItems end')
performance.measure('observeItems', 'observeItems start', 'observeItems end')
}
forEachCommentItem(callbacks: { added?: CommentItemCallback; removed?: CommentItemCallback }) {
const { added, removed } = callbacks
if (added) {
this.items.forEach(item => added(item))
this.itemAddedCallbacks.push(added)
}
if (removed) {
this.itemRemovedCallbacks.push(removed)
}
}
}

View File

@ -0,0 +1,79 @@
import { childList } from '@/core/observer'
import { CommentItem } from '../comment-item'
import { CommentReplyItem } from '../reply-item'
import { CommentArea } from './base'
export class CommentAreaV1 extends CommentArea {
addMenuItem(
item: CommentReplyItem,
config: { className: string; text: string; action: (e: MouseEvent) => void },
): void {
if (!this.element.contains(item.element)) {
return
}
const operationList = dq(item.element, '.opera-list ul') as HTMLUListElement
if (!operationList) {
throw new Error('Invalid operation list')
}
const { className, text, action } = config
if (dq(operationList, `.${className}`)) {
return
}
const menuItem = document.createElement('li')
menuItem.classList.add(className)
menuItem.textContent = text
menuItem.addEventListener('click', event => {
action(event)
operationList.style.display = 'none'
})
operationList.appendChild(menuItem)
}
parseCommentItem(element: HTMLElement): CommentItem {
const user = element.querySelector('.con .user .name') as HTMLElement
if (!user) {
throw new Error('Invalid comment item')
}
const parseReplyItem = (replyElement: HTMLElement) => {
const replyFace = replyElement.querySelector('.reply-face') as HTMLElement
const replyUser = replyElement.querySelector('.reply-con .user .name') as HTMLElement
return new CommentReplyItem({
id: replyElement.getAttribute('data-id'),
element: replyElement,
userId: replyFace.getAttribute('data-usercard-mid'),
userName: replyUser.textContent,
content: replyElement.querySelector('.text-con').textContent,
timeText: replyElement.querySelector('.info .time, .info .time-location').textContent,
likes: parseInt(replyElement.querySelector('.info .like span').textContent),
})
}
const item = new CommentItem({
id: element.getAttribute('data-id'),
element,
userId: user.getAttribute('data-usercard-mid'),
userName: user.textContent,
content: element.querySelector('.con .text').textContent,
timeText: element.querySelector('.con .info .time, .info .time-location').textContent,
likes: parseInt(element.querySelector('.con .like span').textContent),
replies: [],
})
if (dq(element, '.reply-box .view-more')) {
const replyBox = dq(element, '.reply-box') as HTMLElement
childList(replyBox, records => {
item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
if (records.length !== 0) {
item.dispatchRepliesUpdate(item.replies)
}
})
} else {
item.replies = dqa(element, '.reply-box .reply-item').map(parseReplyItem)
}
return item
}
getCommentId(element: HTMLElement): string {
const attributeId = element.getAttribute('data-id')
if (!attributeId) {
throw new Error('Invalid comment item')
}
return attributeId
}
}

View File

@ -0,0 +1,157 @@
/* eslint-disable no-underscore-dangle */
import { childList } from '@/core/observer'
import { CommentItem } from '../comment-item'
import { CommentArea } from './base'
import { CommentReplyItem } from '../reply-item'
import { HTMLElementWithVue, VNodeManager } from '../vnode-manager'
export class CommentAreaV2 extends CommentArea {
private vnodeManager: VNodeManager
constructor(element: HTMLElement) {
super(element)
this.vnodeManager = new VNodeManager(this.getVNodeRoot(element as HTMLElementWithVue))
this.vnodeManager.isEnd = vnode => {
if (vnode.props?.reply) {
return vnode.props?.reply.replies.length === 0
}
return Boolean(vnode.props?.subReply)
}
}
protected beforeParse(elements: HTMLElementWithVue[]) {
const rootElements = new Set<HTMLElementWithVue>()
elements.forEach(replyElement => {
rootElements.add(this.vnodeManager.traverseToRoot(replyElement))
dqa(replyElement, '.sub-reply-item').forEach(subReplyElement => {
rootElements.add(this.vnodeManager.traverseToRoot(subReplyElement as HTMLElementWithVue))
})
})
rootElements.forEach(rootElement => this.vnodeManager.exposeVNode(rootElement._vnode))
}
/** 获取 Vue 数据 (评论 / 回复) */
protected getReplyFromVueData(element: HTMLElement) {
const props = (element as HTMLElementWithVue)._vnode?.props
return props?.reply ?? props?.subReply
}
/** 获取回复对应的元素 */
protected getReplyItemElement(parent: HTMLElement, replyId: string) {
const [replyElement] = dqa(parent, '.sub-reply-item').filter(
(it: HTMLElement) => this.getReplyFromVueData(it).rpid_str === replyId,
)
return replyElement as HTMLElement
}
protected getVNodeRoot(element: HTMLElementWithVue) {
if (element.__vue_app__) {
return element
}
if (element.parentElement.__vue_app__) {
return element.parentElement
}
return null
}
addMenuItem(
item: CommentReplyItem,
config: { className: string; text: string; action: (e: MouseEvent) => void },
): void {
if (!this.element.contains(item.element)) {
return
}
const operationList = dq(item.element, '.operation-list') as HTMLUListElement
const { className, text, action } = config
if (!operationList || dq(operationList, `.${className}`)) {
return
}
const menuItem = document.createElement('li')
menuItem.classList.add(className, 'operation-option')
menuItem.innerHTML = `<span class="option-title">${text}</span>`
menuItem.addEventListener('click', event => {
action(event)
operationList.style.display = 'none'
})
operationList.appendChild(menuItem)
}
parseCommentItem(element: HTMLElement): CommentItem {
const vueData = this.getReplyFromVueData(element)
if (!vueData) {
throw new Error('Invalid comment item')
}
const parseReplies = () => {
if (!vueData.replies) {
return []
}
return vueData.replies.map((r: any): CommentReplyItem => {
return new CommentReplyItem({
id: r.rpid_str,
element: this.getReplyItemElement(element, r.rpid_str),
userId: r.member.mid,
userName: r.member.uname,
content: r.content.message,
time: r.ctime * 1000,
likes: r.like,
})
})
}
const item = new CommentItem({
id: vueData.rpid_str,
element,
userId: vueData.member.mid,
userName: vueData.member.uname,
content: vueData.content.message,
time: vueData.ctime * 1000,
likes: vueData.like,
pictures: vueData.content?.pictures?.map((img: any) => {
return img.img_src
}),
replies: parseReplies(),
})
if (item.replies.length < vueData.rcount) {
const replyBox = dq(element, '.sub-reply-list')
childList(replyBox, records => {
performance.mark(`parseReplies start ${item.id}`)
const addedCommentElements: HTMLElement[] = []
records.forEach(r => {
r.addedNodes.forEach(n => {
if (n instanceof HTMLElement && n.matches('.sub-reply-item')) {
addedCommentElements.push(n)
}
})
})
if (addedCommentElements.length === 0) {
return
}
this.beforeParse(addedCommentElements)
item.replies = parseReplies()
if (records.length !== 0) {
item.dispatchRepliesUpdate(item.replies)
}
performance.mark(`parseReplies end ${item.id}`)
performance.measure(
`parseReplies ${item.id}`,
`parseReplies start ${item.id}`,
`parseReplies end ${item.id}`,
)
})
}
return item
}
getCommentId(element: HTMLElement): string {
const vueData = this.getReplyFromVueData(element)
if (!vueData?.rpid_str) {
throw new Error('Invalid comment item')
}
return vueData.rpid_str
}
static isV2Area(element: HTMLElement) {
return (
(element as HTMLElementWithVue).__vue_app__ ||
(element as HTMLElementWithVue).parentElement?.__vue_app__
)
}
}

View File

@ -0,0 +1,44 @@
import { allMutations } from '@/core/observer'
import { getCommentArea } from './comment-area'
import { CommentAreaCallback, CommentItemCallback } from './types'
import { CommentArea } from './areas/base'
export class CommentAreaManager {
/** 当前页面所有的评论区列表 */
commentAreas: CommentArea[] = []
commentAreaCallbacks: CommentAreaCallback[] = []
protected static commentAreaClasses = ['bili-comment', 'bb-comment']
init() {
allMutations(records => {
records.forEach(r => {
r.addedNodes.forEach(n => this.observeAreas(n))
})
})
dqa(CommentAreaManager.commentAreaClasses.map(c => `.${c}`).join(',')).forEach(it =>
this.observeAreas(it),
)
}
observeAreas(node: Node) {
if (
node instanceof HTMLElement &&
CommentAreaManager.commentAreaClasses.some(c => node.classList.contains(c))
) {
const area = getCommentArea(node)
this.commentAreas.push(area)
area.observeItems()
this.commentAreaCallbacks.forEach(c => c(area))
}
}
forEachCommentArea(callback: CommentAreaCallback) {
this.commentAreas.forEach(it => callback(it))
this.commentAreaCallbacks.push(callback)
}
forEachCommentItem(callbacks: { added?: CommentItemCallback; removed?: CommentItemCallback }) {
this.forEachCommentArea(area => area.forEachCommentItem(callbacks))
}
}

View File

@ -0,0 +1,10 @@
import type { CommentArea } from './areas/base'
import { CommentAreaV1 } from './areas/v1'
import { CommentAreaV2 } from './areas/v2'
export const getCommentArea = (element: HTMLElement): CommentArea => {
if (CommentAreaV2.isV2Area(element)) {
return new CommentAreaV2(element)
}
return new CommentAreaV1(element)
}

View File

@ -0,0 +1,42 @@
import { CommentReplyItem } from './reply-item'
import { RepliesUpdateEventCallback, RepliesUpdateEventType } from './types'
const createRepliesUpdateEvent = (replies: CommentReplyItem[]) => {
return new CustomEvent(RepliesUpdateEventType, {
detail: replies,
})
}
/** 表示一条评论 */
export class CommentItem extends CommentReplyItem {
/** 评论图片 */
pictures?: string[]
/** 回复 */
replies: CommentReplyItem[]
constructor(initParams: Omit<CommentItem, keyof EventTarget | 'dispatchRepliesUpdate'>) {
super(initParams)
this.pictures = initParams.pictures ?? []
this.replies = initParams.replies
}
addEventListener(
type: typeof RepliesUpdateEventType,
callback: RepliesUpdateEventCallback | null,
options?: EventListenerOptions | boolean,
) {
super.addEventListener(type, callback, options)
}
removeEventListener(
type: typeof RepliesUpdateEventType,
callback: RepliesUpdateEventCallback | null,
options?: EventListenerOptions | boolean,
) {
super.removeEventListener(type, callback, options)
}
/** 触发评论更新事件 */
dispatchRepliesUpdate(replies: CommentReplyItem[]) {
return super.dispatchEvent(createRepliesUpdateEvent(replies))
}
}

View File

@ -0,0 +1,31 @@
/** 表示一条评论回复 */
export class CommentReplyItem extends EventTarget {
/** 对应元素 */
element: HTMLElement
/** 评论ID */
id: string
/** 评论者UID */
userId: string
/** 用户名 */
userName: string
/** 评论内容 */
content: string
/** (仅 v1) 评论时间 (文本) */
timeText?: string
/** (仅 v2) 评论时间戳 */
time?: number
/** 点赞数 */
likes: number
constructor(initParams: Omit<CommentReplyItem, keyof EventTarget>) {
super()
this.element = initParams.element
this.id = initParams.id
this.userId = initParams.userId
this.userName = initParams.userName
this.content = initParams.content
this.timeText = initParams.timeText
this.time = initParams.time
this.likes = initParams.likes
}
}

View File

@ -0,0 +1,10 @@
import type { CommentReplyItem } from './reply-item'
import type { CommentItem } from './comment-item'
import type { CommentArea } from './comment-area'
/** 评论更新的事件类型 */
export const RepliesUpdateEventType = 'repliesUpdate'
/** 评论更新的事件回调类型 */
export type RepliesUpdateEventCallback = (event: CustomEvent<CommentReplyItem[]>) => void
export type CommentItemCallback = (item: CommentItem) => void
export type CommentAreaCallback = (area: CommentArea) => void

View File

@ -0,0 +1,103 @@
/* eslint-disable no-underscore-dangle */
export const VNodeTargets = Symbol.for('VNodeTargets')
export interface HTMLElementWithVue extends HTMLElement {
_vnode?: any
__vue_app__?: any
parentElement: HTMLElementWithVue | null
[VNodeTargets]?: HTMLElementWithVue[]
}
export interface Vue3Vnode {
type: string | symbol
el?: HTMLElementWithVue
props?: Record<string, any>
component?: {
subTree?: Vue3Vnode
}
children?: Vue3Vnode[]
}
export class VNodeManager {
public isEnd: (vnode: Vue3Vnode) => boolean = () => true
constructor(public rootElement: HTMLElementWithVue) {}
/** 从结束元素向上写入递归范围 */
traverseToRoot(element: HTMLElementWithVue): HTMLElementWithVue {
performance.mark('traverseToRoot')
if (element._vnode) {
return element
}
let currentElement = element
while (currentElement.parentElement !== null && currentElement !== this.rootElement) {
if (currentElement.parentElement[VNodeTargets]) {
if (!currentElement.parentElement[VNodeTargets].includes(currentElement)) {
currentElement.parentElement[VNodeTargets].push(currentElement)
}
if (currentElement.parentElement._vnode) {
return currentElement.parentElement
}
// 如果其他 element 已经标记过此处, 可以直接 break 掉完成循环
break
}
currentElement.parentElement[VNodeTargets] = [currentElement]
if (currentElement.parentElement._vnode) {
return currentElement.parentElement
}
currentElement = currentElement.parentElement
}
return this.rootElement
}
/** 解开组件实例的包装 */
private unwrapSubtree(vnode: Vue3Vnode): Vue3Vnode {
if (vnode.component?.subTree) {
performance.mark('unwrapSubtree')
return this.unwrapSubtree(vnode.component?.subTree)
}
return vnode
}
/**
* VNode
* @see https://github.com/the1812/Bilibili-Evolved/issues/4690#issuecomment-2059485344
*/
exposeVNode(vnode: Vue3Vnode = this.rootElement._vnode) {
performance.mark('exposeVNode')
if (vnode.el && !vnode.el._vnode) {
vnode.el._vnode = vnode
}
if (this.isEnd(vnode)) {
return
}
const unwrapped = this.unwrapSubtree(vnode)
if (!Array.isArray(unwrapped.children)) {
return
}
const nextChildren = unwrapped.children.filter(child => {
// fragment 需要将 VNodeTargets 直接向下传递一层
if (child.type === Symbol.for('v-fgt')) {
child.el[VNodeTargets] = unwrapped.el?.[VNodeTargets]
return true
}
// 模板树需要 flatMap 并向下传递一层
if (!child.el) {
child[VNodeTargets] = unwrapped.el?.[VNodeTargets]?.flatMap(
element => element?.[VNodeTargets],
)
return true
}
// 没有 el 时属于来自模板树的传递
if (!unwrapped.el) {
return unwrapped[VNodeTargets]?.includes(child.el)
}
return unwrapped.el?.[VNodeTargets]?.includes(child.el)
})
nextChildren.forEach(child => {
this.exposeVNode(child)
})
}
}

View File

@ -1,4 +1,3 @@
/* eslint-disable class-methods-use-this */
import { select } from '@/core/spin-query'
import { raiseEvent } from '@/core/utils'
import {

View File

@ -68,7 +68,6 @@ export class VideoInfo {
}
/** @deprecated */
// eslint-disable-next-line class-methods-use-this
get subtitles(): {
id: number
languageCode: string

View File

@ -407,7 +407,6 @@ export class DoubleClickEvent {
singleClickHandler: (e: MouseEvent) => void = none
private clickedOnce = false
// eslint-disable-next-line class-methods-use-this
private readonly stopPropagationHandler = (e: MouseEvent) => {
e.stopImmediatePropagation()
}
@ -701,3 +700,8 @@ export const simulateClick = (target: EventTarget, eventParams?: PointerEventIni
target.dispatchEvent(mouseUpEvent)
target.dispatchEvent(clickEventEvent)
}
/** 尝试获取元素对应的 Vue Data (仅适用于 Vue 2 组件) */
export const getVue2Data = (el: any) =>
// eslint-disable-next-line no-underscore-dangle
el.__vue__ ?? el.parentElement.__vue__ ?? el.children[0].__vue__ ?? el.__vueParentComponent