diff --git a/.eslintignore b/.eslintignore index 14e38d70ab..c0ba0a5b9b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,6 @@ packages/ typings/ -dist/ +**/dist/ dev/ node_modules/ !.github-json/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 586e80f5f1..6b6ce433a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,6 +31,7 @@ "cntr", "compositionend", "compositionstart", + "contenteditable", "csrf", "ctnr", "Danmaku", @@ -43,6 +44,7 @@ "durl", "epid", "esbuild", + "ffmetadata", "flac", "Fullscreen", "githubusercontent", diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bf39b5c6..eda46a55ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,45 @@ # 更新日志 +## v2.9.3-preview +主要是各种修复, 见 [v2.9.3](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.9.3). + +## v2.9.3 + +
+正式版用户将获得 v2.9.1-preview ~ v2.9.2-preview 的所有改动, 点击展开查看 + +✨新增 +- `查看封面` 可以为 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)) +- `弹幕转义` 支持对正斜杠的换行 (`/n`) 进行转义. (#4865) +- `自定义顶栏` 支持直接在功能中打开布局设置. (#2666) +- `高分辨率图片` 支持处理没有指定高度的图片, 支持在专栏页面中请求原图. (#2868) +- `直播间网页全屏自适应` 样式适配较低的宽度值. (#4895) + +☕开发者相关 +- 外部资源接入 Subresource Integrity. (#4896) + +
+ +🐛修复 +- 修复 `快速收起评论` 对旧版评论区的兼容性. (#4905) +- 修复 `快捷键扩展` 的发送评论在新版评论区失效. (#4843) +- 修复 `禁用评论区搜索词` 偶现样式失效. (#4843) + +☕开发者相关 +- `ShadowDomObserver` 在使用前无需再调用 `observe()`. +- 增加工具方法 `getActiveElement` 检测当前页面的聚焦元素. + ## v2.9.2-preview `2024-09-08` diff --git a/doc/features/features.json b/doc/features/features.json index eb3ee9ee81..176fc61fe9 100644 --- a/doc/features/features.json +++ b/doc/features/features.json @@ -143,6 +143,14 @@ "fullRelativePath": "../../registry/dist/components/live/home-mute.js", "fullAbsolutePath": "registry/dist/components/live/home-mute.js" }, + { + "type": "component", + "name": "liveroomUsernameLink", + "displayName": "添加直播间用户超链接", + "description": "by [@Light_Quanta](https://github.com/LightQuanta)\n\n为直播间的房间观众和大航海界面的用户列表添加可以点击的超链接", + "fullRelativePath": "../../registry/dist/components/live/liveroom-username-link.js", + "fullAbsolutePath": "registry/dist/components/live/liveroom-username-link.js" + }, { "type": "component", "name": "originalLiveroom", @@ -699,7 +707,7 @@ "type": "component", "name": "unescapeDanmaku", "displayName": "弹幕转义", - "description": "将弹幕中的 `\\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.\r\n", + "description": "将弹幕中的 `\\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.\r\n", "fullRelativePath": "../../registry/dist/components/video/danmaku/unescape.js", "fullAbsolutePath": "registry/dist/components/video/danmaku/unescape.js" }, @@ -735,6 +743,14 @@ "fullRelativePath": "../../registry/dist/components/video/full-episode-title.js", "fullAbsolutePath": "registry/dist/components/video/full-episode-title.js" }, + { + "type": "component", + "name": "saveVideoMetadata", + "displayName": "保存视频元数据", + "description": "by [@WakelessSloth56](https://github.com/WakelessSloth56),[@LainIO24](https://github.com/LainIO24)\n\n保存视频元数据(标题、描述、UP、章节等)", + "fullRelativePath": "../../registry/dist/components/video/metadata.js", + "fullAbsolutePath": "registry/dist/components/video/metadata.js" + }, { "type": "component", "name": "outerWatchlater", diff --git a/doc/features/features.md b/doc/features/features.md index 7e143954e2..570495dddf 100644 --- a/doc/features/features.md +++ b/doc/features/features.md @@ -179,6 +179,17 @@ by [@TimmyOVO](https://github.com/TimmyOVO) 禁止直播首页的推荐直播间自动开始播放. +### [添加直播间用户超链接](../../registry/dist/components/live/liveroom-username-link.js) +`liveroomUsernameLink` + +**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/live/liveroom-username-link.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/live/liveroom-username-link.js) + +**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/live/liveroom-username-link.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/live/liveroom-username-link.js) + +by [@Light_Quanta](https://github.com/LightQuanta) + +为直播间的房间观众和大航海界面的用户列表添加可以点击的超链接 + ### [返回原版直播间](../../registry/dist/components/live/original.js) `originalLiveroom` @@ -958,7 +969,7 @@ by [@kdxcxs](https://github.com/kdxcxs) **GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/danmaku/unescape.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/danmaku/unescape.js) -将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠. +将弹幕中的 `\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠. ### [视频页默认定位](../../registry/dist/components/video/default-location.js) `videoDefaultLocation` @@ -1022,6 +1033,17 @@ by [@kdxcxs](https://github.com/kdxcxs) 打开 `展开选集列表` 时, 在选集区域的标题上按住 Alt 键点击可以临时切换展开/收起选集列表. +### [保存视频元数据](../../registry/dist/components/video/metadata.js) +`saveVideoMetadata` + +**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/metadata.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/video/metadata.js) + +**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/metadata.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/metadata.js) + +by [@WakelessSloth56](https://github.com/WakelessSloth56),[@LainIO24](https://github.com/LainIO24) + +保存视频元数据(标题、描述、UP、章节等) + ### [外置稍后再看](../../registry/dist/components/video/outer-watchlater.js) `outerWatchlater` diff --git a/registry/lib/components/feeds/fold-comments/fold-comment.scss b/registry/lib/components/feeds/fold-comments/fold-comment.scss index 66f4b9e91d..441e18c0fa 100644 --- a/registry/lib/components/feeds/fold-comments/fold-comment.scss +++ b/registry/lib/components/feeds/fold-comments/fold-comment.scss @@ -28,10 +28,13 @@ } } .bili-comment-container { + display: flex !important; + flex-direction: column !important; &.bili-dyn-comment .reply-list { padding-bottom: 8px !important; } .fold-comment { + order: 1; font-size: 13px; } .at-panel, diff --git a/registry/lib/components/feeds/fold-comments/index.ts b/registry/lib/components/feeds/fold-comments/index.ts index 4ddce9bc21..724a776745 100644 --- a/registry/lib/components/feeds/fold-comments/index.ts +++ b/registry/lib/components/feeds/fold-comments/index.ts @@ -32,10 +32,7 @@ const entry = async () => { } if (feedsCardsManager.managerType === 'v2') { const getExistingComment = () => dq(card, commentSelector) as HTMLElement - const isCommentAreaReady = () => { - const existingComment = getExistingComment() - return existingComment !== null && dq(existingComment, 'bili-comments') - } + const isCommentAreaReady = () => getExistingComment() !== null const handler = () => { const button = dq(card, '.bili-dyn-action.comment') as HTMLElement button?.click() diff --git a/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss b/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss index a07c769cda..f3d150d8d5 100644 --- a/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss +++ b/registry/lib/components/live/chat-panel-fit/chat-panel-fit.scss @@ -1,10 +1,30 @@ .player-full-win:not(.hide-aside-area) { .live-room-app { .aside-area { + container-name: aside-area; + container-type: size; width: var(--live-chat-panel-width, 302px) !important; } .player-section { width: calc(100% - var(--live-chat-panel-width, 302px)) !important; } + @container aside-area (max-width: 290px) { + .control-panel-icon-row-new .icon-left-part-new { + .super-chat, + .like-btn { + width: 32px; + &-icon { + margin-right: 0 !important; + } + &-text { + display: none !important; + } + } + } + .chat-input-ctnr-new .medal-section { + min-width: 0 !important; + max-width: 0 !important; + } + } } } diff --git a/registry/lib/components/live/liveroom-username-link/index.ts b/registry/lib/components/live/liveroom-username-link/index.ts new file mode 100644 index 0000000000..a55bf1a1f2 --- /dev/null +++ b/registry/lib/components/live/liveroom-username-link/index.ts @@ -0,0 +1,104 @@ +import { defineComponentMetadata } from '@/components/define' +import { select } from '@/core/spin-query' +import { delay } from '@/core/utils' + +const processed = new WeakSet() + +const entry = async () => { + const userInfoBar = await select('#rank-list-ctnr-box', { + queryInterval: 500, + }) + + const observer = new MutationObserver(async () => { + // 舰长列表 + const guardNodes = [...document.querySelectorAll('webcomponent-userinfo')] + let subtreeLoaded = false + + for (const node of guardNodes) { + if (processed.has(node)) { + continue + } + + // eslint-disable-next-line no-underscore-dangle + const { uid } = (node as any).__vue__.source.uinfo + + if (!subtreeLoaded) { + // 等待子节点创建 + while ( + node.shadowRoot.querySelector('a') === null || + node.shadowRoot.querySelector('.faceBox') === null + ) { + await delay(100) + } + subtreeLoaded = true + } + + const aNode = node.shadowRoot.querySelector('a') + const avatarNode: HTMLDivElement = node.shadowRoot.querySelector('.faceBox') + + aNode.href = `https://space.bilibili.com/${uid}` + aNode.style.textDecoration = 'none' + + avatarNode.style.cursor = 'pointer' + avatarNode.addEventListener('click', () => { + window.open(`https://space.bilibili.com/${uid}`) + }) + processed.add(node) + + // const name = a.innerText + // console.log(`已为舰长${name}(UID: ${uid})添加超链接`) + } + + // 观众列表 + const spectorNodes = [...document.querySelectorAll('.gift-rank-list-item')] + for (const node of spectorNodes) { + if (processed.has(node)) { + continue + } + + // 观众列表元素似乎会原地更新,不能直接预先获取UID并绑定,这里通过点击时获取父元素动态读取UID + + // 名称 + const nameNode: HTMLDivElement = node.querySelector('.common-nickname-wrapper .name') + nameNode.style.cursor = 'pointer' + nameNode.addEventListener('click', () => { + // eslint-disable-next-line no-underscore-dangle + const { uid } = (nameNode as any).parentNode.parentNode.parentNode.parentNode.__vue__.source + window.open(`https://space.bilibili.com/${uid}`) + }) + + // 头像 + const avatarNode: HTMLDivElement = node.querySelector('.face') + avatarNode.style.cursor = 'pointer' + avatarNode.addEventListener('click', () => { + // eslint-disable-next-line no-underscore-dangle + const { uid } = (avatarNode as any).parentNode.parentNode.__vue__.source + window.open(`https://space.bilibili.com/${uid}`) + }) + processed.add(node) + + // const name = nameNode.innerText + // console.log(`已为观众${name}(UID: ${uid})添加超链接`) + } + }) + + observer.observe(userInfoBar, { + childList: true, + subtree: true, + }) +} + +export const component = defineComponentMetadata({ + name: 'liveroomUsernameLink', + author: { + name: 'Light_Quanta', + link: 'https://github.com/LightQuanta', + }, + displayName: '添加直播间用户超链接', + entry, + tags: [componentsTags.live], + urlInclude: [/^https:\/\/live\.bilibili\.com\/\d+/], + description: { + 'zh-CN': '为直播间的房间观众和大航海界面的用户列表添加可以点击的超链接', + }, +}) diff --git a/registry/lib/components/style/custom-navbar/index.ts b/registry/lib/components/style/custom-navbar/index.ts index 98eaf05797..4e9385bd73 100644 --- a/registry/lib/components/style/custom-navbar/index.ts +++ b/registry/lib/components/style/custom-navbar/index.ts @@ -133,6 +133,9 @@ export const component = defineComponentMetadata({ // const { addImportantStyle } = await import('@/core/style') // addImportantStyle(style, styleID) }, + widget: { + component: () => import('./settings/Widget.vue').then(m => m.default), + }, extraOptions: () => import('./settings/ExtraOptions.vue').then(m => m.default), plugin: { displayName: '自定义顶栏 - 功能扩展', diff --git a/registry/lib/components/style/custom-navbar/settings/Widget.vue b/registry/lib/components/style/custom-navbar/settings/Widget.vue new file mode 100644 index 0000000000..286ee464e6 --- /dev/null +++ b/registry/lib/components/style/custom-navbar/settings/Widget.vue @@ -0,0 +1,24 @@ + + diff --git a/registry/lib/components/style/simplify/home/home.scss b/registry/lib/components/style/simplify/home/home.scss index 66e28c38fd..b5343ba160 100644 --- a/registry/lib/components/style/simplify/home/home.scss +++ b/registry/lib/components/style/simplify/home/home.scss @@ -1,33 +1,46 @@ body.simplifyHome-switch { + &-carousel { + .recommended-swipe.grid-anchor { + display: none !important; + } + } + &-categories { .z-top-container.has-menu { height: auto !important; min-height: unset !important; } - .bili-header-m > .bili-wrapper { + + .bili-header-m>.bili-wrapper { visibility: hidden !important; height: 18px !important; } + .primary-menu-itnl { visibility: hidden !important; height: 24px !important; padding: 0 !important; } + .bili-header__channel { height: 12px !important; } - .bili-header__channel > * { + + .bili-header__channel>* { display: none !important; } + &.header-v3 .bili-wrapper { padding-top: 8px !important; border-top: none !important; } } + &-trends { .first-screen #reportFirst1 { display: none !important; } + .first-screen .space-between { margin-bottom: 0 !important; } @@ -38,30 +51,36 @@ body.simplifyHome-switch { display: none !important; } } + &-online { .first-screen #reportFirst2 { display: none !important; } } + &-ext-box { .first-screen #reportFirst3 { display: none !important; } } + &-special { #bili_report_spe_rec { display: none !important; } } + &-contact { + .bili-footer .b-footer-wrap, .international-footer { display: none !important; } } + &-elevator { .storey-box .elevator { display: none !important; } } -} +} \ No newline at end of file diff --git a/registry/lib/components/style/simplify/home/index.ts b/registry/lib/components/style/simplify/home/index.ts index f1bddf73aa..b2bd121afd 100644 --- a/registry/lib/components/style/simplify/home/index.ts +++ b/registry/lib/components/style/simplify/home/index.ts @@ -10,6 +10,10 @@ import { mainSiteUrls } from '@/core/utils/urls' const switchMetadata = defineSwitchMetadata({ name: 'simplifyOptions', switches: { + carousel: { + defaultValue: false, + displayName: '轮播图', + }, categories: { defaultValue: false, displayName: '分区栏', @@ -74,15 +78,17 @@ export const component = wrapSwitchOptions(switchMetadata)({ () => dqa('.proxy-box > div'), elements => elements.length > 0 || !isHome, ) - return Object.fromEntries( - categoryElements.map(it => [ - it.id.replace(/^bili_/, ''), - { - displayName: it.querySelector('header .name')?.textContent?.trim() ?? '未知分区', - defaultValue: false, - }, - ]), - ) + return categoryElements + ? Object.fromEntries( + categoryElements.map(it => [ + it.id.replace(/^bili_/, ''), + { + displayName: it.querySelector('header .name')?.textContent?.trim() ?? '未知分区', + defaultValue: false, + }, + ]), + ) + : {} } const skipIds = ['推广'] diff --git a/registry/lib/components/utils/image-resolution/index.ts b/registry/lib/components/utils/image-resolution/index.ts index f42d2bc44f..8158ecf563 100644 --- a/registry/lib/components/utils/image-resolution/index.ts +++ b/registry/lib/components/utils/image-resolution/index.ts @@ -11,6 +11,10 @@ const options = defineOptionsMetadata({ defaultValue: 'auto', hidden: true, }, + originalImageInArticles: { + displayName: '在专栏中请求原图', + defaultValue: false, + }, }) export type Options = OptionsOfMetadata diff --git a/registry/lib/components/utils/image-resolution/resolution.ts b/registry/lib/components/utils/image-resolution/resolution.ts index 0d910a1777..bdc04d0427 100644 --- a/registry/lib/components/utils/image-resolution/resolution.ts +++ b/registry/lib/components/utils/image-resolution/resolution.ts @@ -1,7 +1,7 @@ import { styledComponentEntry } from '@/components/styled-component' import { Options } from '.' -const resizeRegex = /@(\d+)[Ww]_(\d+)[Hh]/ +const resizeRegex = /@(\d+)[Ww](_(\d+)[Hh])?/ /** 排除 */ const excludeSelectors = ['#certify-img1', '#certify-img2'] @@ -17,6 +17,7 @@ const widthAndHeightSelectors = [ // https://github.com/the1812/Bilibili-Evolved/issues/4480 '.logo-img', ] +const originalImageInArticlesSelectors = ['.article-detail .article-content img'] const walk = (rootElement: Node, action: (node: HTMLElement) => void) => { const walker = document.createNodeIterator(rootElement, NodeFilter.SHOW_ELEMENT) @@ -26,12 +27,21 @@ const walk = (rootElement: Node, action: (node: HTMLElement) => void) => { node = walker.nextNode() } } + +interface ImageResolutionHandler { + getWidth: (width: number, element: HTMLElement) => number + getHeight: (height: number, element: HTMLElement) => number +} + /** * 从开始元素`element`向下遍历所有子节点, 更换其中的图片URL至目标DPI * @param dpi 目标DPI * @param element 开始元素 */ -export const imageResolution = async (dpi: number, element: HTMLElement) => { +export const imageResolution = async ( + element: HTMLElement, + resolutionHandler: ImageResolutionHandler, +) => { const { attributes } = await import('@/core/observer') const replaceSource = ( getValue: (e: HTMLElement) => string | null, @@ -48,16 +58,22 @@ export const imageResolution = async (dpi: number, element: HTMLElement) => { if (value.includes(',')) { return } + const match = value.match(resizeRegex) if (!match) { return } - const [, currentWidth, currentHeight] = match + const [, currentWidth, , currentHeight] = match const lastWidth = parseInt(element.getAttribute('data-resolution-width') || '0') if (parseInt(currentWidth) >= lastWidth && lastWidth !== 0) { return } - if (element.getAttribute('width') === null && element.getAttribute('height') === null) { + + if ( + element.getAttribute('width') === null && + element.getAttribute('height') === null && + currentHeight !== undefined + ) { if (widthAndHeightSelectors.some(selector => element.matches(selector))) { element.setAttribute('height', currentHeight) element.setAttribute('width', currentWidth) @@ -67,10 +83,26 @@ export const imageResolution = async (dpi: number, element: HTMLElement) => { element.setAttribute('width', currentWidth) } } - const newWidth = Math.round(dpi * parseInt(currentWidth)).toString() - const newHeight = Math.round(dpi * parseInt(currentHeight)).toString() - element.setAttribute('data-resolution-width', newWidth) - setValue(element, value.replace(resizeRegex, `@${newWidth}w_${newHeight}h`)) + + const getReplacedValue = (newWidth: number, newHeight?: number) => { + if (newWidth === Infinity || newHeight === Infinity) { + return value.replace(resizeRegex, '@') + } + if (newHeight === undefined) { + return value.replace(resizeRegex, `@${newWidth}w`) + } + return value.replace(resizeRegex, `@${newWidth}w_${newHeight}h`) + } + if (currentHeight !== undefined) { + const newWidth = resolutionHandler.getWidth(parseInt(currentWidth), element) + const newHeight = resolutionHandler.getHeight(parseInt(currentHeight), element) + element.setAttribute('data-resolution-width', newWidth.toString()) + setValue(element, getReplacedValue(newWidth, newHeight)) + } else { + const newWidth = resolutionHandler.getWidth(parseInt(currentWidth), element) + element.setAttribute('data-resolution-width', newWidth.toString()) + setValue(element, getReplacedValue(newWidth)) + } } attributes(element, () => { replaceSource( @@ -95,14 +127,35 @@ export const startResolution = styledComponentEntry( settings.options.scale === 'auto' ? window.devicePixelRatio : parseFloat(settings.options.scale) - walk(document.body, it => imageResolution(dpi, it)) + const handleResolution: ImageResolutionHandler = { + getWidth: (currentWidth, element) => { + if ( + settings.options.originalImageInArticles && + originalImageInArticlesSelectors.some(selector => element.matches(selector)) + ) { + return Infinity + } + return Math.round(dpi * currentWidth) + }, + getHeight: (currentHeight, element) => { + if ( + settings.options.originalImageInArticles && + originalImageInArticlesSelectors.some(selector => element.matches(selector)) + ) { + return Infinity + } + return Math.round(dpi * currentHeight) + }, + } + + walk(document.body, it => imageResolution(it, handleResolution)) allMutations(records => { records.forEach(record => record.addedNodes.forEach(node => { if (node instanceof HTMLElement) { - imageResolution(dpi, node) + imageResolution(node, handleResolution) if (node.nodeName.toUpperCase() !== 'IMG') { - walk(node, it => imageResolution(dpi, it)) + walk(node, it => imageResolution(it, handleResolution)) } } }), diff --git a/registry/lib/components/utils/keymap/actions.ts b/registry/lib/components/utils/keymap/actions.ts index 89c7add0c3..2bb249c400 100644 --- a/registry/lib/components/utils/keymap/actions.ts +++ b/registry/lib/components/utils/keymap/actions.ts @@ -3,7 +3,7 @@ import { getComponentSettings } from '@/core/settings' import { registerAndGetData } from '@/plugins/data' import { Options } from '.' import { KeyBindingAction, KeyBindingActionContext } from './bindings' -import { simulateClick } from '@/core/utils' +import { getActiveElement, simulateClick } from '@/core/utils' export const keyboardEventToPointer = (event: KeyboardEvent): PointerEventInit => { return { @@ -239,15 +239,24 @@ export const builtInActions: Record = { sendComment: { displayName: '发送评论', ignoreTyping: false, + prevent: true, run: () => { - const { activeElement } = document - if (!activeElement || !(activeElement instanceof HTMLTextAreaElement)) { + const activeElement = getActiveElement() + if (!activeElement) { return null } + const isEditable = + activeElement instanceof HTMLTextAreaElement || + activeElement.hasAttribute('contenteditable') + if (!isEditable) { + return null + } + const getShadowRoot = (node: Node) => node.getRootNode() as ShadowRoot | null const sendButton = (() => { const candidates = [ () => activeElement.nextElementSibling, () => activeElement.parentElement.nextElementSibling, + () => getShadowRoot(getShadowRoot(activeElement)?.host)?.querySelector('#pub button'), () => dq('.reply-box:focus-within .reply-box-send'), ] const match = candidates.find(fn => fn() !== null) diff --git a/registry/lib/components/utils/keymap/bindings.ts b/registry/lib/components/utils/keymap/bindings.ts index f86b835082..bb30371a43 100644 --- a/registry/lib/components/utils/keymap/bindings.ts +++ b/registry/lib/components/utils/keymap/bindings.ts @@ -1,6 +1,7 @@ import { isTyping, matchUrlPattern } from '@/core/utils' import { mediaListUrls, watchlaterUrls } from '@/core/utils/urls' import { clickElement, changeVideoTime, showTip } from './actions' +import { shadowDomObserver } from '@/core/shadow-root' export interface KeyBindingActionContext { binding: KeyBinding @@ -29,7 +30,7 @@ export const loadKeyBindings = lodash.once((bindings: KeyBinding[]) => { enable: true, bindings, } - document.body.addEventListener('keydown', (e: KeyboardEvent & { [key: string]: boolean }) => { + const keyboardHandler = (e: KeyboardEvent & { [key: string]: boolean }) => { if (!config.enable) { return } @@ -87,10 +88,15 @@ export const loadKeyBindings = lodash.once((bindings: KeyBinding[]) => { const actionSuccess = !lodash.isNil(actionResult) if (binding.action.prevent ?? actionSuccess) { - e.stopPropagation() + e.stopImmediatePropagation() e.preventDefault() } }) + } + document.body.addEventListener('keydown', keyboardHandler, { capture: true }) + shadowDomObserver.watchShadowDom({ + added: shadowDom => + shadowDom.shadowRoot.addEventListener('keydown', keyboardHandler, { capture: true }), }) return config }) diff --git a/registry/lib/components/utils/view-cover/index.ts b/registry/lib/components/utils/view-cover/index.ts index b56af3d7c0..4c5fef9af1 100644 --- a/registry/lib/components/utils/view-cover/index.ts +++ b/registry/lib/components/utils/view-cover/index.ts @@ -1,5 +1,5 @@ import { defineComponentMetadata } from '@/components/define' -import { getBlobByAid } from '@/components/video/video-cover' +import { getVideoCoverUrlByAid, getBlobByAid } from '@/components/video/video-cover' import { PackageEntry } from '@/core/download' import { videoAndBangumiUrls } from '@/core/utils/urls' import { Toast } from '@/core/toast' @@ -55,6 +55,26 @@ export const component = defineComponentMetadata({ toast.message = `获取完成. 成功 ${success.length} 个, 失败 ${fail.length} 个.` return success.map(it => it.value) }, + getUrls: async ( + infos, + instance: { + type: CoverDownloadType + enabled: boolean + }, + ) => { + const { type, enabled } = instance + if (!enabled) { + return [] + } + return Promise.all( + infos.map(async info => { + return { + name: `${info.input.title}.${type}`, + url: await getVideoCoverUrlByAid(info.input.aid), + } + }), + ) + }, component: () => import('./Plugin.vue').then(m => m.default), }) }) diff --git a/registry/lib/components/video/danmaku/unescape/index.md b/registry/lib/components/video/danmaku/unescape/index.md index e84e415316..f2cd57af7f 100644 --- a/registry/lib/components/video/danmaku/unescape/index.md +++ b/registry/lib/components/video/danmaku/unescape/index.md @@ -1 +1 @@ -将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠. +将弹幕中的 `\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠. diff --git a/registry/lib/components/video/danmaku/unescape/index.ts b/registry/lib/components/video/danmaku/unescape/index.ts index 4e8118923d..35ed7c08d6 100644 --- a/registry/lib/components/video/danmaku/unescape/index.ts +++ b/registry/lib/components/video/danmaku/unescape/index.ts @@ -5,13 +5,39 @@ export const component = defineComponentMetadata({ name: 'unescapeDanmaku', displayName: '弹幕转义', tags: [componentsTags.video], - entry: () => { - const newLineRegex = /\\n/g + options: { + backSlash: { + defaultValue: true, + displayName: '对 \\n 转义', + }, + forwardSlash: { + defaultValue: true, + displayName: '对 /n 转义', + }, + }, + entry: ({ settings }) => { + const newLineRegex = (() => { + if (settings.options.backSlash && settings.options.forwardSlash) { + return /\\n|\/n/g + } + if (settings.options.backSlash) { + return /\\n/g + } + return /\/n/g + })() + const setText = (element: Element, text: string): void => { + const children = [...element.children] + if (children.length > 0) { + children.forEach(child => setText(child, text)) + } + const textNodes = [...element.childNodes].filter(it => it.nodeType === Node.TEXT_NODE) + textNodes.forEach(node => (node.textContent = text)) + } forEachVideoDanmaku({ added: danmaku => { if (newLineRegex.test(danmaku.text)) { const newText = danmaku.text.replace(newLineRegex, '\n') - danmaku.element.textContent = newText + setText(danmaku.element, newText) } }, }) diff --git a/registry/lib/components/video/download/DownloadVideo.vue b/registry/lib/components/video/download/DownloadVideo.vue index 1e66bc16ad..6a1a637cd8 100644 --- a/registry/lib/components/video/download/DownloadVideo.vue +++ b/registry/lib/components/video/download/DownloadVideo.vue @@ -330,19 +330,15 @@ export default Vue.extend({ }) } const action = new DownloadVideoAction(videoInfos) - const extraAssets = ( - await Promise.all( - assets.map(a => - a.getAssets( - videoInfos, - this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name), - ), - ), - ) - ).flat() - action.extraAssets.push(...extraAssets) - await action.downloadExtraAssets() + assets.forEach(a => { + const assetsType = a?.getUrls ? action.extraOnlineAssets : action.extraAssets + assetsType.push({ + asset: a, + instance: this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name), + }) + }) await output.runAction(action, instance) + await action.downloadExtraAssets() } catch (error) { logError(error) } finally { diff --git a/registry/lib/components/video/download/types.ts b/registry/lib/components/video/download/types.ts index 9f68a33ae8..676c7a5d8f 100644 --- a/registry/lib/components/video/download/types.ts +++ b/registry/lib/components/video/download/types.ts @@ -77,11 +77,19 @@ export interface DownloadVideoApi extends WithName { /** 表示下载时额外附带的产物, 如弹幕 / 字幕等 */ export interface DownloadVideoAssets extends VueInstanceInput, WithName { getAssets: (infos: DownloadVideoInfo[], instance: AssetsParameter) => Promise + /** 获取可直接下载的链接 */ + getUrls?: ( + infos: DownloadVideoInfo[], + instance: AssetsParameter, + ) => Promise<{ name: string; url: string }[]> } /** 表示视频的下载信息以及携带的额外产物 */ -export class DownloadVideoAction { +export class DownloadVideoAction { readonly inputs: DownloadVideoInputItem[] = [] - extraAssets: PackageEntry[] = [] + /** 可调用处理的asset和对应的参数 */ + extraAssets: { asset: DownloadVideoAssets; instance: AssetsParameter }[] = [] + /** 可直接下载的asset和对应的参数 */ + extraOnlineAssets: { asset: DownloadVideoAssets; instance: AssetsParameter }[] = [] constructor(public infos: DownloadVideoInfo[]) { this.inputs = infos.map(it => it.input) @@ -92,7 +100,15 @@ export class DownloadVideoAction { async downloadExtraAssets() { console.log('[downloadExtraAssets]', this.extraAssets) const filename = `${getFriendlyTitle(false)}.zip` - await new DownloadPackage(this.extraAssets).emit(filename) + const { infos } = this + const extraAssetsBlob = ( + await Promise.all( + [...this.extraAssets, ...this.extraOnlineAssets].map(({ asset, instance }) => + asset.getAssets(infos, instance), + ), + ) + ).flat() + await new DownloadPackage(extraAssetsBlob).emit(filename) } } /** 下载视频的最终输出处理 */ diff --git a/registry/lib/components/video/metadata/Plugin.vue b/registry/lib/components/video/metadata/Plugin.vue new file mode 100644 index 0000000000..6165a7e297 --- /dev/null +++ b/registry/lib/components/video/metadata/Plugin.vue @@ -0,0 +1,44 @@ + + diff --git a/registry/lib/components/video/metadata/SaveMetadata.vue b/registry/lib/components/video/metadata/SaveMetadata.vue new file mode 100644 index 0000000000..1704d86c0d --- /dev/null +++ b/registry/lib/components/video/metadata/SaveMetadata.vue @@ -0,0 +1,48 @@ + + + diff --git a/registry/lib/components/video/metadata/index.ts b/registry/lib/components/video/metadata/index.ts new file mode 100644 index 0000000000..bcdbdecea1 --- /dev/null +++ b/registry/lib/components/video/metadata/index.ts @@ -0,0 +1,72 @@ +import { defineComponentMetadata } from '@/components/define' +import { PackageEntry } from '@/core/download' +import { hasVideo } from '@/core/spin-query' +import { Toast } from '@/core/toast' +import { videoUrls } from '@/core/utils/urls' +import { DownloadVideoAssets } from '../download/types' +import { generateByType, MetadataType } from './metadata' + +export const title = '保存视频元数据' +export const name = 'saveVideoMetadata' + +const author = [ + { + name: 'WakelessSloth56', + link: 'https://github.com/WakelessSloth56', + }, + { + name: 'LainIO24', + link: 'https://github.com/LainIO24', + }, +] + +export const component = defineComponentMetadata({ + name, + displayName: title, + description: '保存视频元数据(标题、描述、UP、章节等)', + author, + tags: [componentsTags.video], + entry: none, + urlInclude: videoUrls, + widget: { + condition: hasVideo, + component: () => import('./SaveMetadata.vue').then(m => m.default), + }, + plugin: { + displayName: `下载视频 - ${title}支持`, + author, + setup: ({ addData }) => { + addData('downloadVideo.assets', async (assets: DownloadVideoAssets[]) => { + assets.push({ + name, + displayName: title, + getAssets: async ( + infos, + instance: { + type: MetadataType + enabled: boolean + }, + ) => { + const { type, enabled } = instance + if (enabled) { + const toast = Toast.info('获取视频元数据中...', title) + const result: PackageEntry[] = [] + for (const info of infos) { + result.push({ + name: `${info.input.title}.${type}.txt`, + data: await generateByType(type, info.input.aid, info.input.cid), + options: {}, + }) + } + toast.message = '完成!' + toast.duration = 1000 + return result + } + return [] + }, + component: () => import('./Plugin.vue').then(m => m.default), + }) + }) + }, + }, +}) diff --git a/registry/lib/components/video/metadata/metadata.ts b/registry/lib/components/video/metadata/metadata.ts new file mode 100644 index 0000000000..6f30f52cab --- /dev/null +++ b/registry/lib/components/video/metadata/metadata.ts @@ -0,0 +1,152 @@ +import { VideoInfo, VideoPageInfo } from '@/components/video/video-info' +import { VideoQuality } from '@/components/video/video-quality' +import { bilibiliApi, getJsonWithCredentials } from '@/core/ajax' +import { meta } from '@/core/meta' +import { Toast } from '@/core/toast' +import { title as pluginTitle } from '.' + +export type MetadataType = 'ffmetadata' | 'ogm' + +function escape(s: string) { + return s.replace(/[=;#\\\n]/g, r => `\\${r}`) +} + +interface ViewPoint { + content: string + from: number + to: number + image: string +} + +class VideoMetadata { + #aid: string + #cid: number | string + + basic: VideoInfo + + viewPoints: ViewPoint[] + page: VideoPageInfo + quality?: VideoQuality + + constructor(aid: string, cid: number | string) { + this.#aid = aid + this.#cid = cid + this.basic = new VideoInfo(aid) + } + + async fetch() { + await this.basic.fetchInfo() + this.page = this.basic.pages.filter(p => p.cid === parseInt(this.#cid))[0] + + const playInfo = await bilibiliApi( + getJsonWithCredentials( + `https://api.bilibili.com/x/player/wbi/v2?aid=${this.#aid}&cid=${this.#cid}`, + ), + ) + + this.viewPoints = lodash.get(playInfo, 'view_points', []) as ViewPoint[] + } +} + +async function fetchMetadata(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) { + const data = new VideoMetadata(aid, cid) + await data.fetch() + return data +} + +function ff(key: string, value: any, prefix = true) { + return `${prefix ? 'bilibili_' : ''}${key}=${escape(lodash.toString(value))}` +} + +async function generateFFMetadata(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) { + const data = await fetchMetadata(aid, cid) + const info = data.basic + + const lines = [ + ';FFMETADATA1', + `;generated by Bilibili-Evolved v${meta.compilationInfo.version}`, + `;generated on ${new Date().toLocaleString()}`, + // Standard fields + ff('title', `${info.title} - ${data.page.title}`, false), + ff('description', info.description, false), + ff('artist', info.up.name, false), + // Custom fields + ff('title', info.title), + ff('description', info.description), + ff('publish_date', new Date(info.pubdate * 1000).toLocaleString()), + ff('aid', info.aid), + ff('bvid', info.bvid), + ff('cid', data.page.cid), + ff('category_id', info.tagId), + ff('category_name', info.tagName), + ff('page_title', data.page.title), + ff('page', data.page.pageNumber), + ff('pages', info.pages.length), + ff('up_name', info.up.name), + ff('up_uid', info.up.uid), + ] + + if (data.quality) { + lines.push(ff('quality', data.quality.value)) + lines.push(ff('quality_label', data.quality.name)) + } + + if (data.viewPoints.length > 0) { + for (const chapter of data.viewPoints) { + lines.push( + ...[ + '[CHAPTER]', + 'TIMEBASE=1/1', + ff('START', chapter.from, false), + ff('END', chapter.to, false), + ff('title', chapter.content, false), + ], + ) + } + } + + const result = lines.join('\n') + + console.debug(result) + return result +} + +async function generateChapterFile(aid: string = unsafeWindow.aid, cid: string = unsafeWindow.cid) { + const { viewPoints } = await fetchMetadata(aid, cid) + console.debug(viewPoints) + if (viewPoints.length > 0) { + const result = viewPoints + .reduce((p, v, i) => { + const n = `${i + 1}`.padStart(3, '0') + return [ + ...p, + `CHAPTER${n}=${new Date(v.from * 1000).toISOString().slice(11, -1)}`, + `CHAPTER${n}NAME=${v.content}`, + ] + }, []) + .join('\n') + + console.debug(result) + return result + } + Toast.info('此视频没有章节', pluginTitle, 3000) + return null +} + +export async function generateByType( + type: MetadataType, + aid: string = unsafeWindow.aid, + cid: string = unsafeWindow.cid, +) { + let method: (aid, cid) => Promise + switch (type) { + case 'ogm': + method = generateChapterFile + break + default: + case 'ffmetadata': + method = generateFFMetadata + break + } + return method(aid, cid) +} diff --git a/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue b/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue index 067638faa3..52e9e2f01a 100644 --- a/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue +++ b/registry/lib/plugins/video/download/aria2-output/RpcConfig.vue @@ -1,5 +1,9 @@