mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
Merge branch 'preview-fixes'
This commit is contained in:
commit
9e5159f7d2
@ -1,6 +1,6 @@
|
||||
packages/
|
||||
typings/
|
||||
dist/
|
||||
**/dist/
|
||||
dev/
|
||||
node_modules/
|
||||
!.github-json/
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -31,6 +31,7 @@
|
||||
"cntr",
|
||||
"compositionend",
|
||||
"compositionstart",
|
||||
"contenteditable",
|
||||
"csrf",
|
||||
"ctnr",
|
||||
"Danmaku",
|
||||
@ -43,6 +44,7 @@
|
||||
"durl",
|
||||
"epid",
|
||||
"esbuild",
|
||||
"ffmetadata",
|
||||
"flac",
|
||||
"Fullscreen",
|
||||
"githubusercontent",
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@ -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`
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
registry/lib/components/live/liveroom-username-link/index.ts
Normal file
104
registry/lib/components/live/liveroom-username-link/index.ts
Normal 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': '为直播间的房间观众和大航海界面的用户列表添加可以点击的超链接',
|
||||
},
|
||||
})
|
||||
@ -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: '自定义顶栏 - 功能扩展',
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = ['推广']
|
||||
|
||||
@ -11,6 +11,10 @@ const options = defineOptionsMetadata({
|
||||
defaultValue: 'auto',
|
||||
hidden: true,
|
||||
},
|
||||
originalImageInArticles: {
|
||||
displayName: '在专栏中请求原图',
|
||||
defaultValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
export type Options = OptionsOfMetadata<typeof options>
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
@ -1 +1 @@
|
||||
将弹幕中的 `\n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
|
||||
将弹幕中的 `\n` 或 `/n` 替换为真实的换行, 注意这可能导致原先不重叠的弹幕发生重叠.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
/** 下载视频的最终输出处理 */
|
||||
|
||||
44
registry/lib/components/video/metadata/Plugin.vue
Normal file
44
registry/lib/components/video/metadata/Plugin.vue
Normal 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>
|
||||
48
registry/lib/components/video/metadata/SaveMetadata.vue
Normal file
48
registry/lib/components/video/metadata/SaveMetadata.vue
Normal 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>
|
||||
72
registry/lib/components/video/metadata/index.ts
Normal file
72
registry/lib/components/video/metadata/index.ts
Normal 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),
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
152
registry/lib/components/video/metadata/metadata.ts
Normal file
152
registry/lib/components/video/metadata/metadata.ts
Normal 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)
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
41
registry/lib/plugins/video/download/wasm-output/Config.vue
Normal file
41
registry/lib/plugins/video/download/wasm-output/Config.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -33,7 +33,6 @@ export class CommentAreaV3 extends CommentArea {
|
||||
|
||||
constructor(element: HTMLElement) {
|
||||
super(element)
|
||||
shadowDomObserver.observe()
|
||||
this.shadowDomObserver = shadowDomObserver
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user