Merge branch 'preview-fixes'

This commit is contained in:
the1812 2024-09-15 10:16:47 +08:00
commit 9e5159f7d2
43 changed files with 1094 additions and 187 deletions

View File

@ -1,6 +1,6 @@
packages/
typings/
dist/
**/dist/
dev/
node_modules/
!.github-json/

View File

@ -31,6 +31,7 @@
"cntr",
"compositionend",
"compositionstart",
"contenteditable",
"csrf",
"ctnr",
"Danmaku",
@ -43,6 +44,7 @@
"durl",
"epid",
"esbuild",
"ffmetadata",
"flac",
"Fullscreen",
"githubusercontent",

View File

@ -1,6 +1,45 @@
<!-- spell-checker: disable -->
# 更新日志
## v2.9.3-preview
主要是各种修复, 见 [v2.9.3](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.9.3).
## v2.9.3
<details>
<summary>正式版用户将获得 v2.9.1-preview ~ v2.9.2-preview 的所有改动, 点击展开查看</summary>
✨新增
- `查看封面` 可以为 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)
</details>
🐛修复
- 修复 `快速收起评论` 对旧版评论区的兼容性. (#4905)
- 修复 `快捷键扩展` 的发送评论在新版评论区失效. (#4843)
- 修复 `禁用评论区搜索词` 偶现样式失效. (#4843)
☕开发者相关
- `ShadowDomObserver` 在使用前无需再调用 `observe()`.
- 增加工具方法 `getActiveElement` 检测当前页面的聚焦元素.
## v2.9.2-preview
`2024-09-08`

View File

@ -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",

View File

@ -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)
打开 `展开选集列表` 时, 在选集区域的标题上按住 <kbd>Alt</kbd> 键点击可以临时切换展开/收起选集列表.
### [保存视频元数据](../../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`

View File

@ -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,

View File

@ -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()

View File

@ -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;
}
}
}
}

View File

@ -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': '为直播间的房间观众和大航海界面的用户列表添加可以点击的超链接',
},
})

View File

@ -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: '自定义顶栏 - 功能扩展',

View File

@ -0,0 +1,24 @@
<template>
<DefaultWidget
name="顶栏布局设置"
icon="mdi-sort"
@mouseover="loadNavbarSettings()"
@click="toggleNavbarSettings()"
/>
</template>
<script lang="ts">
import { DefaultWidget } from '@/ui'
import { loadNavbarSettings, toggleNavbarSettings } from './vm'
export default Vue.extend({
components: {
DefaultWidget,
},
methods: {
async loadNavbarSettings() {
await loadNavbarSettings()
},
toggleNavbarSettings,
},
})
</script>

View File

@ -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;
}
}
}
}

View File

@ -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 = ['推广']

View File

@ -11,6 +11,10 @@ const options = defineOptionsMetadata({
defaultValue: 'auto',
hidden: true,
},
originalImageInArticles: {
displayName: '在专栏中请求原图',
defaultValue: false,
},
})
export type Options = OptionsOfMetadata<typeof options>

View File

@ -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<Options>(
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))
}
}
}),

View File

@ -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<string, KeyBindingAction> = {
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)

View File

@ -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
})

View File

@ -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),
})
})

View File

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

View File

@ -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)
}
},
})

View File

@ -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 {

View File

@ -77,11 +77,19 @@ export interface DownloadVideoApi extends WithName {
/** 表示下载时额外附带的产物, 如弹幕 / 字幕等 */
export interface DownloadVideoAssets<AssetsParameter = any> extends VueInstanceInput, WithName {
getAssets: (infos: DownloadVideoInfo[], instance: AssetsParameter) => Promise<PackageEntry[]>
/** 获取可直接下载的链接 */
getUrls?: (
infos: DownloadVideoInfo[],
instance: AssetsParameter,
) => Promise<{ name: string; url: string }[]>
}
/** 表示视频的下载信息以及携带的额外产物 */
export class DownloadVideoAction {
export class DownloadVideoAction<AssetsParameter = any> {
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)
}
}
/** 下载视频的最终输出处理 */

View File

@ -0,0 +1,44 @@
<template>
<div class="download-video-config-section">
<div class="download-video-config-item">
<div>元数据</div>
<VDropdown v-model="type" :items="items">
<template #item="{ item }">
{{ item }}
</template>
</VDropdown>
</div>
</div>
</template>
<script lang="ts">
import { VDropdown } from '@/ui'
import { getComponentSettings } from '@/core/settings'
import { MetadataType } from './metadata'
interface Options {
metadataType: MetadataType | '无'
}
const options = getComponentSettings('downloadVideo').options as Options
export default Vue.extend({
components: {
VDropdown,
},
data() {
return {
type: options.metadataType ?? '无',
items: ['无', 'ffmetadata', 'ogm'],
}
},
computed: {
enabled() {
return this.type !== '无'
},
},
watch: {
type(value: MetadataType) {
options.metadataType = value
},
},
})
</script>

View File

@ -0,0 +1,48 @@
<template>
<div class="multiple-widgets">
<DefaultWidget
ref="button"
:disabled="disabled"
name="保存视频元数据"
icon="mdi-download"
@click="run('ffmetadata')"
></DefaultWidget>
<DefaultWidget
:disabled="disabled"
name="保存视频章节"
icon="mdi-download"
@click="run('ogm')"
></DefaultWidget>
</div>
</template>
<script lang="ts">
import { DefaultWidget } from '@/ui'
import { logError } from '@/core/utils/log'
import { DownloadPackage } from '@/core/download'
import { getFriendlyTitle } from '@/core/utils/title'
import { MetadataType, generateByType } from './metadata'
export default Vue.extend({
components: {
DefaultWidget,
},
data() {
return {
disabled: false,
}
},
methods: {
async run(type: MetadataType) {
try {
this.disabled = true
DownloadPackage.single(`${getFriendlyTitle(true)}.${type}.txt`, await generateByType(type))
} catch (error) {
logError(error)
} finally {
this.disabled = false
}
},
},
})
</script>

View File

@ -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),
})
})
},
},
})

View File

@ -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(<any>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<string>
switch (type) {
case 'ogm':
method = generateChapterFile
break
default:
case 'ffmetadata':
method = generateFFMetadata
break
}
return method(aid, cid)
}

View File

@ -1,5 +1,9 @@
<template>
<div class="rpc-config download-video-config-section">
<div>
<div>aria2下载附属资源若支持:</div>
<SwitchBox v-model="isPluginDownloadAssets" @change="saveSettings" />
</div>
<div v-if="isRenaming" class="profile-select">
<div class="profile-item-name">重命名 RPC 预设:</div>
<TextBox ref="renameInput" v-model="profileRename" />
@ -69,17 +73,19 @@
<script lang="ts">
import { getComponentSettings } from '@/core/settings'
import { Toast } from '@/core/toast'
import { TextBox, VButton, VIcon, VDropdown, TextArea } from '@/ui'
import { TextBox, VButton, VIcon, VDropdown, TextArea, SwitchBox } from '@/ui'
import { Aria2RpcProfile, defaultProfile } from './rpc-profiles'
interface Options {
rpcProfiles: Aria2RpcProfile[]
selectedRpcProfileName: string
isPluginDownloadAssets: boolean
}
const { options: storedOptions } = getComponentSettings('downloadVideo')
const defaultOptions: Options = {
rpcProfiles: [defaultProfile],
selectedRpcProfileName: defaultProfile.name,
isPluginDownloadAssets: false,
}
const options = { ...defaultOptions, ...storedOptions }
const handleMissingProfile = () => {
@ -99,6 +105,7 @@ export default Vue.extend({
VIcon,
VDropdown,
TextArea,
SwitchBox,
},
data() {
return {
@ -106,12 +113,14 @@ export default Vue.extend({
profileRename: '',
rpcProfiles: options.rpcProfiles,
selectedRpcProfile: lastSelectedProfile,
isPluginDownloadAssets: options.isPluginDownloadAssets,
}
},
methods: {
saveSettings() {
options.selectedRpcProfileName = this.selectedRpcProfile.name
options.rpcProfiles = this.rpcProfiles
options.isPluginDownloadAssets = this.isPluginDownloadAssets
Object.assign(storedOptions, options)
},
async startRename() {

View File

@ -131,35 +131,59 @@ export const aria2Rpc: DownloadVideoOutput = {
action,
instance: Vue & {
selectedRpcProfile: Aria2RpcProfile
isPluginDownloadAssets?: boolean
},
) => {
const { infos } = action
const { selectedRpcProfile } = instance
const { infos, extraOnlineAssets } = action
const { selectedRpcProfile, isPluginDownloadAssets } = instance
const { secretKey, dir, other } = selectedRpcProfile
const referer = document.URL.replace(window.location.search, '')
const totalParams = infos
const ariaParamsGenerator = (url: string, title: string) => {
const singleInfoParams = []
if (secretKey) {
singleInfoParams.push(`token:${secretKey}`)
}
singleInfoParams.push([url])
singleInfoParams.push({
referer,
'user-agent': UserAgent,
out: title,
dir: dir || undefined,
...parseRpcOptions(other),
})
const id = encodeURIComponent(title)
return {
params: singleInfoParams,
id,
}
}
// handle video params
const videoParams = infos
.map(info =>
info.titledFragments.map(fragment => {
const singleInfoParams = []
if (secretKey) {
singleInfoParams.push(`token:${secretKey}`)
}
singleInfoParams.push([fragment.url])
singleInfoParams.push({
referer,
'user-agent': UserAgent,
out: fragment.title,
dir: dir || undefined,
...parseRpcOptions(other),
})
const id = encodeURIComponent(fragment.title)
return {
params: singleInfoParams,
id,
}
const { url, title } = fragment
return ariaParamsGenerator(url, title)
}),
)
.flat()
// handle assets
const assetsAriaParams = []
const extraAssetsForBrowerDownload = []
for (const { asset, instance: assetInstance } of extraOnlineAssets) {
if (isPluginDownloadAssets && 'getUrls' in asset) {
// get asset from aria2
const results = await asset.getUrls(infos, assetInstance)
assetsAriaParams.push(...results.map(({ name, url }) => ariaParamsGenerator(url, name)))
} else {
// remain asset in `extraOnlineAssets`
extraAssetsForBrowerDownload.push({ asset, instance: assetInstance })
}
}
action.extraOnlineAssets = extraAssetsForBrowerDownload
const totalParams = [...videoParams, ...assetsAriaParams]
const results = await sendRpc(selectedRpcProfile, totalParams)
console.table(results)
if (results.length === 1) {

View File

@ -0,0 +1,41 @@
<template>
<div v-if="shouldShow" class="download-video-config-item" style="flex-wrap: wrap">
<div class="download-video-config-title">写入元数据</div>
<SwitchBox v-model="muxWithMetadata" @change="saveOptions" />
<div class="download-video-config-description" style="width: 100%">
仅支持元数据类型ffmetadata
</div>
</div>
</template>
<script lang="ts">
import { SwitchBox } from '@/ui'
import { isComponentEnabled, getComponentSettings } from '@/core/settings'
interface Options {
muxWithMetadata: boolean
}
const defaultOptions: Options = {
muxWithMetadata: false,
}
const { options: storedOptions } = getComponentSettings('downloadVideo')
const options: Options = { ...defaultOptions, ...storedOptions }
export default Vue.extend({
components: {
SwitchBox,
},
data() {
const shouldShow = isComponentEnabled('saveVideoMetadata')
return {
shouldShow,
muxWithMetadata: shouldShow && options.muxWithMetadata,
}
},
methods: {
saveOptions() {
options.muxWithMetadata = this.muxExtraAssets
Object.assign(storedOptions, options)
},
},
})
</script>

View File

@ -1,4 +1,4 @@
import { DownloadPackage } from '@/core/download'
import { DownloadPackage, PackageEntry } from '@/core/download'
import { meta } from '@/core/meta'
import { getComponentSettings } from '@/core/settings'
import { Toast } from '@/core/toast'
@ -6,38 +6,38 @@ import { title as pluginTitle } from '.'
import type { Options } from '../../../../components/video/download'
import { DownloadVideoAction } from '../../../../components/video/download/types'
import { FFmpeg } from './ffmpeg'
import { getCacheOrGet, httpGet, toastProgress, toBlobUrl } from './utils'
import { getCacheOrFetch, httpGet, toastProgress, toBlobUrl } from './utils'
const ffmpeg = new FFmpeg()
async function loadFFmpeg() {
const toast = Toast.info('正在加载 FFmpeg', `${pluginTitle} - 初始化`)
const progress = toastProgress(toast)
const [worker, core, wasm] = await Promise.all([
getCacheOrFetch(
'ffmpeg-worker',
meta.compilationInfo.altCdn.library.ffmpeg.worker,
progress(0, '正在加载 FFmpeg Worker'),
),
getCacheOrFetch(
'ffmpeg-core',
meta.compilationInfo.altCdn.library.ffmpeg.core,
progress(1, '正在加载 FFmpeg Core'),
),
getCacheOrFetch(
'ffmpeg-wasm',
meta.compilationInfo.altCdn.library.ffmpeg.wasm,
progress(2, '正在加载 FFmpeg WASM'),
),
])
await ffmpeg.load({
workerLoadURL: toBlobUrl(
await getCacheOrGet(
'ffmpeg-worker',
meta.compilationInfo.altCdn.library.ffmpeg.worker,
toastProgress(toast, '正在加载 FFmpeg Worker'),
),
'text/javascript',
),
coreURL: toBlobUrl(
await getCacheOrGet(
'ffmpeg-core',
meta.compilationInfo.altCdn.library.ffmpeg.core,
toastProgress(toast, '正在加载 FFmpeg Core'),
),
'text/javascript',
),
wasmURL: toBlobUrl(
await getCacheOrGet(
'ffmpeg-wasm',
meta.compilationInfo.altCdn.library.ffmpeg.wasm,
toastProgress(toast, '正在加载 FFmpeg WASM'),
),
'application/wasm',
),
workerLoadURL: toBlobUrl(worker, 'text/javascript'),
coreURL: toBlobUrl(core, 'text/javascript'),
wasmURL: toBlobUrl(wasm, 'application/wasm'),
})
toast.message = '完成!'
toast.close()
}
@ -46,52 +46,76 @@ async function single(
name: string,
videoUrl: string,
audioUrl: string,
isFlac: boolean,
ffmetadata: string,
outputMkv: boolean,
pageIndex = 1,
totalPages = 1,
) {
const toast = Toast.info('', `${pluginTitle} - ${pageIndex} / ${totalPages}`)
ffmpeg.writeFile('video', await httpGet(videoUrl, toastProgress(toast, '正在下载视频流')))
ffmpeg.writeFile('audio', await httpGet(audioUrl, toastProgress(toast, '正在下载音频流')))
toast.message = '混流中……'
const outputExt = isFlac ? 'mkv' : 'mp4'
name = name.replace(/.[^/.]+$/, `.${outputExt}`)
await ffmpeg.exec([
'-i',
'video',
'-i',
'audio',
'-c:v',
'copy',
'-c:a',
'copy',
'-f',
isFlac ? 'matroska' : 'mp4',
`output.${outputExt}`,
const progress = toastProgress(toast)
const [video, audio] = await Promise.all([
httpGet(videoUrl, progress(0, '正在下载视频流')),
httpGet(audioUrl, progress(1, '正在下载音频流')),
])
const output = await ffmpeg.readFile(`output.${outputExt}`)
ffmpeg.writeFile('video', video)
ffmpeg.writeFile('audio', audio)
const args = ['-i', 'video', '-i', 'audio']
if (ffmetadata) {
ffmpeg.writeFile('ffmetadata', new TextEncoder().encode(ffmetadata))
args.push('-i', 'ffmetadata', '-map_metadata', '2')
if (!outputMkv) {
args.push('-movflags', '+use_metadata_tags')
}
}
args.push('-codec', 'copy', '-f', outputMkv ? 'matroska' : 'mp4', 'output')
console.debug('FFmpeg commandline args:', args.join(' '))
toast.message = '混流中……'
await ffmpeg.exec(args)
const output = await ffmpeg.readFile('output')
const outputBlob = new Blob([output], {
type: isFlac ? 'video/x-matroska' : 'video/mp4',
type: outputMkv ? 'video/x-matroska' : 'video/mp4',
})
toast.message = '完成!'
toast.duration = 1000
await DownloadPackage.single(name, outputBlob)
await DownloadPackage.single(
name.replace(/.[^/.]+$/, `.${outputMkv ? 'mkv' : 'mp4'}`),
outputBlob,
)
}
export async function run(action: DownloadVideoAction) {
export async function run(action: DownloadVideoAction, muxWithMetadata: boolean) {
if (!ffmpeg.loaded) {
await loadFFmpeg()
}
const { infos: pages, extraAssets } = action
let ffmetadata: PackageEntry[]
if (muxWithMetadata) {
const extraAssetsForBrowser = []
for (const { asset, instance } of extraAssets) {
if (!ffmetadata && asset.name === 'saveVideoMetadata' && instance.type === 'ffmetadata') {
ffmetadata = await asset.getAssets(pages, instance)
} else {
extraAssetsForBrowser.push({ asset, instance })
}
}
action.extraAssets = extraAssetsForBrowser
}
const { dashAudioExtension, dashFlacAudioExtension, dashVideoExtension } =
getComponentSettings<Options>('downloadVideo').options
const pages = action.infos
for (let i = 0; i < pages.length; i++) {
const page = pages[i]
const [video, audio] = page.titledFragments
@ -109,6 +133,7 @@ export async function run(action: DownloadVideoAction) {
video.title,
video.url,
audio.url,
<string>ffmetadata?.[i]?.data,
audio.extension === dashFlacAudioExtension,
i + 1,
pages.length,

View File

@ -19,14 +19,15 @@ export const plugin: PluginMetadata = {
outputs.push({
name: 'wasm',
displayName: 'WASM',
description: `${desc}运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
runAction: async action => {
description: `${desc}运行过程中请勿关闭页面,初次使用或清除缓存后需要加载约 30 MB 的 WASM 文件`,
runAction: async (action, instance) => {
try {
await run(action)
await run(action, instance.muxWithMetadata)
} catch (error) {
Toast.error(String(error), title)
}
},
component: () => import('./Config.vue').then(m => m.default),
})
})
},

View File

@ -1,14 +1,23 @@
import { Toast } from '@/core/toast'
import { formatFileSize, formatPercent } from '@/core/utils/formatters'
import { getOrLoad, storeNames } from './database'
import { RuntimeLibraryDefinition, RuntimeLibrary } from '@/core/runtime-library'
type OnProgress = (received: number, length: number) => void
type OnProgress = (received: number, total: number) => void
export function toastProgress(toast: Toast, message: string): OnProgress {
return (r, l) => {
toast.message = `${message}: ${formatFileSize(r)}${
l > 0 ? ` / ${formatFileSize(l)} @ ${formatPercent(r / l)}` : ''
}`
function formatProgress(received: number, total: number) {
return `${formatFileSize(received)}${
total > 0 ? ` / ${formatFileSize(total)} @ ${formatPercent(received / total)}` : ''
}`
}
export function toastProgress(toast: Toast) {
const lines = []
return (line: number, message: string): OnProgress => {
return (r, l) => {
lines[line] = `${message}: ${formatProgress(r, l)}`
toast.message = lines.join('\n')
}
}
}
@ -48,8 +57,21 @@ export async function httpGet(url: string, onprogress: OnProgress) {
return chunksAll
}
export async function getCacheOrGet(key: string, url: string, loading: OnProgress) {
return getOrLoad(storeNames.cache, key, async () => httpGet(url, loading))
export async function getCacheOrFetch(
key: string,
library: RuntimeLibraryDefinition,
loading: OnProgress,
) {
return getOrLoad(storeNames.cache, key, async () => {
const content = await httpGet(library.url, loading)
const sha256 = await RuntimeLibrary.sha256(content)
if (sha256 !== library.sha256) {
throw new Error(
`Check integrity failed from ${library.url}, expected = ${library.sha256}, actual = ${sha256}`,
)
}
return content
})
}
export function toBlobUrl(buffer: Uint8Array, mimeType: string) {

View File

@ -1,5 +1,5 @@
{
"version": "2.9.2",
"version": "2.9.3",
"author": "Grant Howard, Coulomb-G",
"copyright": "[year], Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)",
"license": "MIT",
@ -38,7 +38,7 @@
"*"
],
"require": [
"[altCdn.library.lodash]"
"[altCdn.library.lodash.url]#sha256=[altCdn.library.lodash.sha256]"
],
"icon": "[altCdn.smallLogo]",
"icon64": "[altCdn.logo]"

View File

@ -5,7 +5,7 @@
<VIcon
class="switch-icon"
icon="mdi-checkbox-marked-circle-outline"
:size="smallSize ? 16 : 22"
:size="smallSize ? 16 : 24"
></VIcon>
{{ options.optionDisplayName }}
</VButton>
@ -128,6 +128,7 @@ export default Vue.extend({
}
.switch-icon {
margin-right: 8px;
opacity: 0.75;
transform: scale(0.9);
}
.dim {

View File

@ -33,7 +33,6 @@ export class CommentAreaV3 extends CommentArea {
constructor(element: HTMLElement) {
super(element)
shadowDomObserver.observe()
this.shadowDomObserver = shadowDomObserver
}

View File

@ -1,5 +1,17 @@
import { getJsonWithCredentials, getText } from '@/core/ajax'
export interface UpInfo {
uid: number
name: string
faceUrl: string
}
export interface VideoPageInfo {
cid: number
title: string
pageNumber: number
}
export class VideoInfo {
aid: string
bvid: string
@ -13,16 +25,8 @@ export class VideoInfo {
tagName: string
title: string
description: string
up: {
uid: number
name: string
faceUrl: string
}
pages: {
cid: number
title: string
pageNumber: number
}[]
up: UpInfo
pages: VideoPageInfo[]
constructor(id: string, bvid = false) {
if (bvid) {

View File

@ -5,26 +5,54 @@ import type StreamSaverType from 'streamsaver'
import { monkey } from './ajax'
import { meta } from './meta'
export type RuntimeLibraryDefinition = { url: string; sha256: string }
export interface RuntimeLibraryConfig<LibraryType> {
url: string
library: RuntimeLibraryDefinition
getModule: (window: Window) => LibraryType
}
export class RuntimeLibrary<LibraryType> implements PromiseLike<LibraryType> {
private modulePromise: Promise<LibraryType>
constructor(public config: RuntimeLibraryConfig<LibraryType>) {}
static async sha256(content: string | BufferSource) {
const hashBuffer = await window.crypto.subtle.digest(
'SHA-256',
typeof content === 'string' ? new TextEncoder().encode(content) : content,
)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
private async checkIntegrity(content: string | BufferSource) {
const sha256 = await RuntimeLibrary.sha256(content)
if (sha256 !== this.config.library.sha256) {
throw new Error(
`[RuntimeLibrary] Check integrity failed from ${this.config.library.url}, expected = ${this.config.library.sha256}, actual = ${sha256}`,
)
}
console.log(
`[Runtime Library] Checked integrity from ${this.config.library.url}, hash = ${sha256}`,
)
}
async then<Resolve = LibraryType, Reject = never>(
resolve?: (value: LibraryType) => Resolve | PromiseLike<Resolve>,
reject?: (reason: any) => Reject | PromiseLike<Reject>,
) {
try {
const { url, getModule } = this.config
const {
library: { url },
getModule,
} = this.config
if (!this.modulePromise) {
this.modulePromise = (async () => {
console.log(`[Runtime Library] Start download from ${url}`)
const code: string = await monkey({ url })
console.log(`[Runtime Library] Downloaded from ${url} , length = ${code.length}`)
console.log(`[Runtime Library] Downloaded from ${url}, length = ${code.length}`)
await this.checkIntegrity(code)
;(function runEval() {
return eval(code)
// eslint-disable-next-line no-extra-bind
@ -41,18 +69,18 @@ export class RuntimeLibrary<LibraryType> implements PromiseLike<LibraryType> {
}
}
export const protobufLibrary = new RuntimeLibrary<typeof protobufType>({
url: meta.compilationInfo.altCdn.library.protobuf,
library: meta.compilationInfo.altCdn.library.protobuf,
getModule: window => window.protobuf,
})
export const JSZipLibrary = new RuntimeLibrary<typeof JSZipType>({
url: meta.compilationInfo.altCdn.library.jszip,
library: meta.compilationInfo.altCdn.library.jszip,
getModule: window => window.JSZip,
})
export const SortableJSLibrary = new RuntimeLibrary<typeof SortableJSType>({
url: meta.compilationInfo.altCdn.library.sortable,
library: meta.compilationInfo.altCdn.library.sortable,
getModule: window => window.Sortable,
})
export const StreamSaverLibrary = new RuntimeLibrary<typeof StreamSaverType>({
url: meta.compilationInfo.altCdn.library.streamsaver,
library: meta.compilationInfo.altCdn.library.streamsaver,
getModule: window => window.streamSaver,
})

View File

@ -1,3 +1,4 @@
import { contentLoaded } from '../life-cycle'
import { childListSubtree } from '../observer'
import { deleteValue } from '../utils'
import { ShadowDomCallback, ShadowDomEntry } from './dom-entry'
@ -81,6 +82,7 @@ export class ShadowDomObserver extends ShadowRootObserver {
}
forEachShadowDom(callback: ShadowDomCallback) {
this.observe()
const callCurrentAndNextLevel = (currentEntry: ShadowDomEntry) => {
callback(currentEntry)
currentEntry.children.forEach(child => {
@ -94,6 +96,7 @@ export class ShadowDomObserver extends ShadowRootObserver {
}
watchShadowDom(callbacks: { added?: ShadowDomCallback; removed?: ShadowDomCallback }) {
this.observe()
this.forEachShadowDom(it => callbacks.added?.(it))
const addedListener = (e: CustomEvent<ShadowDomEntry>) => callbacks?.added?.(e.detail)
const removedListener = (e: CustomEvent<ShadowDomEntry>) => callbacks?.removed?.(e.detail)
@ -109,10 +112,14 @@ export class ShadowDomObserver extends ShadowRootObserver {
if (this.observing) {
return
}
const existingRoots = ShadowRootObserver.queryAllShadowRoots()
existingRoots.forEach(root => this.addEntry(root))
;[this.rootObserver] = childListSubtree(document.body, records => this.mutationHandler(records))
this.observing = true
contentLoaded(() => {
const existingRoots = ShadowRootObserver.queryAllShadowRoots()
existingRoots.forEach(root => this.addEntry(root))
;[this.rootObserver] = childListSubtree(document.body, records =>
this.mutationHandler(records),
)
})
}
disconnect() {

View File

@ -29,7 +29,6 @@ export class ShadowRootStyles {
async addStyle(definition: ShadowRootStyleDefinition) {
const { id, style } = definition
this.observer.observe()
const entryId = `shadow-dom-style-${id !== undefined ? lodash.kebabCase(id) : getRandomId()}`
const styleSheet = new CSSStyleSheet()
await styleSheet.replace(style)

View File

@ -518,9 +518,18 @@ export const playerReady = async () => {
// unsafeWindow.aid = info.aid.toString()
// return info.aid as string
// }
/** 获取当前聚焦的元素 */
export const getActiveElement = () => {
let { activeElement } = document
while (activeElement.shadowRoot !== null) {
activeElement = activeElement.shadowRoot.activeElement
}
return activeElement
}
/** 是否正在打字 */
export const isTyping = () => {
const { activeElement } = document
const activeElement = getActiveElement()
if (!activeElement) {
return false
}

View File

@ -10,12 +10,26 @@ export const github: CdnConfig = {
stableClient: `https://${host}/${owner}/Bilibili-Evolved/master/dist/bilibili-evolved.user.js`,
previewClient: `https://${host}/${owner}/Bilibili-Evolved/preview/dist/bilibili-evolved.preview.user.js`,
library: {
lodash: `https://${host}/lodash/lodash/4.17.21/dist/lodash.min.js`,
protobuf: `https://${host}/protobufjs/protobuf.js/v6.10.1/dist/light/protobuf.min.js`,
jszip: `https://${host}/Stuk/jszip/v3.7.1/dist/jszip.min.js`,
sortable: `https://${host}/SortableJS/Sortable/1.14.0/Sortable.min.js`,
mdi: `https://${owner}.github.io/Bilibili-Evolved/static/mdi/mdi.css`,
streamsaver: `https://${host}/jimmywarting/StreamSaver.js/2.0.6/StreamSaver.js`,
lodash: {
url: `https://${host}/lodash/lodash/4.17.21/dist/lodash.min.js`,
sha256: 'a9705dfc47c0763380d851ab1801be6f76019f6b67e40e9b873f8b4a0603f7a9',
},
protobuf: {
url: `https://${host}/protobufjs/protobuf.js/v6.10.1/dist/light/protobuf.min.js`,
sha256: '8978daf871b02d683ecaee371861702a6f31d0a4c52925b7db2bb1655a8bc7d1',
},
jszip: {
url: `https://${host}/Stuk/jszip/v3.7.1/dist/jszip.min.js`,
sha256: 'c9e4a52bac18aee4f3f90d05fbca603f5b0f5bf1ce8c45e60bb4ed3a2cb2ed86',
},
sortable: {
url: `https://${host}/SortableJS/Sortable/1.14.0/Sortable.min.js`,
sha256: '0ea5a6fbfbf5434b606878533cb7a66bcf700f0f08afe908335d0978fb63ad94',
},
streamsaver: {
url: `https://${host}/jimmywarting/StreamSaver.js/2.0.6/StreamSaver.js`,
sha256: 'a110f78e0b092481dc372901c4d57ae50681d773bc9d55e62356f9a22f17e24b',
},
// https://github.com/the1812/Bilibili-Evolved/pull/4521#discussion_r1402084486
ffmpeg: jsDelivr.library.ffmpeg,
},

View File

@ -9,16 +9,39 @@ export const jsDelivr: CdnConfig = {
stableClient: `https://${host}/gh/${owner}/Bilibili-Evolved@master/dist/bilibili-evolved.user.js`,
previewClient: `https://${host}/gh/${owner}/Bilibili-Evolved@preview/dist/bilibili-evolved.preview.user.js`,
library: {
lodash: `https://${host}/npm/lodash@4.17.21/lodash.min.js`,
protobuf: `https://${host}/npm/protobufjs@6.10.1/dist/light/protobuf.min.js`,
jszip: `https://${host}/npm/jszip@3.7.1/dist/jszip.min.js`,
sortable: `https://${host}/npm/sortablejs@1.14.0/Sortable.min.js`,
mdi: `https://${host}/gh/${owner}/Bilibili-Evolved@master/docs/static/mdi/mdi.css`,
streamsaver: `https://${host}/npm/streamsaver@2.0.6/StreamSaver.min.js`,
lodash: {
url: `https://${host}/npm/lodash@4.17.21/lodash.min.js`,
sha256: 'a9705dfc47c0763380d851ab1801be6f76019f6b67e40e9b873f8b4a0603f7a9',
},
protobuf: {
url: `https://${host}/npm/protobufjs@6.10.1/dist/light/protobuf.min.js`,
sha256: '8978daf871b02d683ecaee371861702a6f31d0a4c52925b7db2bb1655a8bc7d1',
},
jszip: {
url: `https://${host}/npm/jszip@3.7.1/dist/jszip.min.js`,
sha256: 'c9e4a52bac18aee4f3f90d05fbca603f5b0f5bf1ce8c45e60bb4ed3a2cb2ed86',
},
sortable: {
url: `https://${host}/npm/sortablejs@1.14.0/Sortable.min.js`,
sha256: '0ea5a6fbfbf5434b606878533cb7a66bcf700f0f08afe908335d0978fb63ad94',
},
streamsaver: {
url: `https://${host}/npm/streamsaver@2.0.6/StreamSaver.min.js`,
sha256: '64f465e51e5992be894c5d42330b781544eda5462069fe6be4c7421f02d28c92',
},
ffmpeg: {
worker: `https://${host}/npm/@ffmpeg/ffmpeg@0.12.4/dist/umd/814.ffmpeg.js`,
core: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js`,
wasm: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm`,
worker: {
url: `https://${host}/npm/@ffmpeg/ffmpeg@0.12.4/dist/umd/814.ffmpeg.js`,
sha256: 'baf19437171b1bccae4416e4da69fb40455b8e67142f79c8ec9da36b1de7fd8a',
},
core: {
url: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js`,
sha256: '6af6b8cd8c878dec6f61f3cd6be16e88f9391dd265e51f20afea5c0f718bfba0',
},
wasm: {
url: `https://${host}/npm/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm`,
sha256: '925bd7ef35d4e0f715254cd650f4cfc68c4ec6ecebf293face72b92da904ddda',
},
},
},
smallLogo: `https://${host}/gh/${owner}/Bilibili-Evolved@preview/images/logo-small.png`,

View File

@ -1,3 +1,4 @@
export type ExternalLibrary = { url: string; sha256: string }
export interface CdnConfig {
name: string
owner: string
@ -5,16 +6,15 @@ export interface CdnConfig {
stableClient: string
previewClient: string
library: {
lodash: string
protobuf: string
jszip: string
sortable: string
mdi: string
streamsaver: string
lodash: ExternalLibrary
protobuf: ExternalLibrary
jszip: ExternalLibrary
sortable: ExternalLibrary
streamsaver: ExternalLibrary
ffmpeg: {
worker: string
core: string
wasm: string
worker: ExternalLibrary
core: ExternalLibrary
wasm: ExternalLibrary
}
}
smallLogo: string