mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import { ComponentSettings, getComponentSettings } from '@/core/settings'
|
||
import { addStyle } from '@/core/style'
|
||
import { Toast } from '@/core/toast'
|
||
import { dea, des } from '@/core/utils'
|
||
import { logError } from '@/core/utils/log'
|
||
import { ascendingSort } from '@/core/utils/sort'
|
||
import { fromEvent, PublishContext, TeardownLogic } from '../common/mini-rxjs'
|
||
import { debounceTime } from '../common/mini-rxjs/operators/debounceTime'
|
||
import { filter } from '../common/mini-rxjs/operators/filter'
|
||
import { asapScheduler, observeOn } from '../common/mini-rxjs/operators/observeOn'
|
||
import { loadStyle } from '../common/mini-rxjs/utils/loadStyle'
|
||
import {
|
||
EntrySpeedComponent,
|
||
MAX_BROWSER_SPEED_VALUE,
|
||
MIN_BROWSER_SPEED_VALUE,
|
||
NATIVE_SUPPORTED_VALUES,
|
||
} from '../common/speed'
|
||
import { PLAYER_AGENT, SpeedContext } from '../common/speed/context'
|
||
import { formatSpeedText, parseSpeedText, splitToSpace, trimLeadingDot } from '../common/speed/utils'
|
||
import type { Options as RememberSpeedOptions } from '../remember-speed/component'
|
||
|
||
export const EXTEND_SPEED_INPUT_CLASS_NAME = 'extend-speed-input'
|
||
export const EXTEND_SPEED_ITEM_CLASS_NAME = 'extend-speed-item'
|
||
/** 步进倍速值大小 */
|
||
export const STEP_SPEED_VALUE = 0.5
|
||
/** 错误信息持续时间(单位:毫秒) */
|
||
export const ERROR_MESSAGE_DURATION = 5000
|
||
/** 计算菜单项 order */
|
||
export const calcOrder = (value: number) => ((MAX_BROWSER_SPEED_VALUE - value) * 10000).toString()
|
||
|
||
export interface Options {
|
||
/** 最大菜单高度 */
|
||
maxMenuHeight: boolean
|
||
/** 隐藏进度条 */
|
||
hideScrollbar: boolean
|
||
/** 扩展倍速列表 */
|
||
extendSpeedList: number[]
|
||
}
|
||
|
||
/**
|
||
* 对于源订阅对象发出的每一对前后数组,计算两个数组的差异信息,生成一个 splice 参数形式的补丁数据提交到下一个订阅对象
|
||
*
|
||
* 该算法来自 Neil Fraser 的 paper,只实现了最简单的预处理部分,即去除两个数组公共的前缀和后缀,然后计算差异部分
|
||
* 这种优化力度在当前「扩展倍速」组件的实现中已经完全足够了。除非设置介于中间值的扩展倍速项,否则生成的补丁应该是最优的
|
||
*
|
||
* @see https://neil.fraser.name/writing/diff/
|
||
*
|
||
*/
|
||
const diff = (previousArr: number[], currentArr: number[]): [number, number, number[]] => {
|
||
let pStartIdx = 0
|
||
let pEndIdx = previousArr.length
|
||
let cStartIdx = 0
|
||
let cEndIdx = currentArr.length
|
||
|
||
while (
|
||
pStartIdx < pEndIdx
|
||
&& cStartIdx < cEndIdx
|
||
&& previousArr[pStartIdx] === currentArr[cStartIdx]
|
||
) {
|
||
pStartIdx++
|
||
cStartIdx++
|
||
}
|
||
|
||
while (
|
||
pStartIdx < pEndIdx
|
||
&& cStartIdx < cEndIdx
|
||
&& previousArr[pEndIdx - 1] === currentArr[cEndIdx - 1]
|
||
) {
|
||
pEndIdx--
|
||
cEndIdx--
|
||
}
|
||
|
||
return [
|
||
pStartIdx,
|
||
pEndIdx - pStartIdx,
|
||
currentArr.slice(cStartIdx, cEndIdx),
|
||
]
|
||
}
|
||
|
||
interface VNode<N extends Node, T = any> {
|
||
tag?: T
|
||
node: N
|
||
destroy(): void
|
||
}
|
||
|
||
const $ = <E extends Record<string, Element>, R extends Element = Element>(
|
||
html: string, scoped = true,
|
||
) => {
|
||
const containerElement = document.createElement('div')
|
||
containerElement.innerHTML = html
|
||
const result = {}
|
||
const root = containerElement.children.item(0)
|
||
const walkChildren = (element: Element) => {
|
||
if (scoped) {
|
||
element.id = `scoped-element-${Math.random().toString(36).replace(/[^a-z0-9]+/g, '')}`
|
||
}
|
||
const dataRef = element.getAttribute('data-ref')
|
||
if (dataRef) {
|
||
result[lodash.camelCase(dataRef)] = element
|
||
}
|
||
for (let i = 0; i < element.children.length; i++) {
|
||
walkChildren(element.children.item(i))
|
||
}
|
||
}
|
||
walkChildren(root)
|
||
return { ...result, root } as (E & { root: R, [index: string]: Element })
|
||
}
|
||
|
||
export class ExtendSpeedComponent extends EntrySpeedComponent<Options> {
|
||
protected static get activeClassName() {
|
||
return trimLeadingDot(
|
||
PLAYER_AGENT.custom.active.selector,
|
||
)
|
||
}
|
||
|
||
protected static get showClassName() {
|
||
return trimLeadingDot(
|
||
PLAYER_AGENT.custom.show.selector,
|
||
)
|
||
}
|
||
|
||
protected static get speedMenuItemClassName() {
|
||
return trimLeadingDot(
|
||
PLAYER_AGENT.custom.speedMenuItem.selector,
|
||
)
|
||
}
|
||
|
||
addSpeedValue(value: number) {
|
||
this.options.extendSpeedList = lodash.sortedUniq(
|
||
this.options.extendSpeedList.concat(value).sort(ascendingSort()),
|
||
)
|
||
}
|
||
|
||
removeSpeedValue(value: number) {
|
||
this.options.extendSpeedList = lodash.without(
|
||
this.options.extendSpeedList,
|
||
value,
|
||
)
|
||
}
|
||
|
||
createInputElement(): VNode<HTMLLIElement> {
|
||
const { input, root, icon } = $<{ input: HTMLInputElement, icon: HTMLElement }, HTMLLIElement>(`
|
||
<li class="${splitToSpace(trimLeadingDot(PLAYER_AGENT.custom.speedMenuItem.selector))} ${EXTEND_SPEED_INPUT_CLASS_NAME}">
|
||
<i data-ref="icon" class="mdi mdi-playlist-plus" style="font-size: 1.5em"></i>
|
||
<input data-ref="input" type="number" title="添加新的倍数值" max="${MAX_BROWSER_SPEED_VALUE}" step="${STEP_SPEED_VALUE}" style="display: none;"></input>
|
||
</li>
|
||
`)
|
||
|
||
const updateRecommendedValue = () => {
|
||
const value = this.speedContext.getAvailableSpeedValues().slice(-1)[0] + STEP_SPEED_VALUE
|
||
const recommendedValue = lodash.toString(value > MAX_BROWSER_SPEED_VALUE ? null : value)
|
||
input.value = recommendedValue
|
||
input.min = recommendedValue
|
||
}
|
||
|
||
this.options.extendSpeedList$.pipe(observeOn(asapScheduler)).subscribe(updateRecommendedValue)
|
||
|
||
const styleElement = addStyle(`
|
||
#${input.id} {
|
||
font-size: inherit;
|
||
color: inherit;
|
||
line-height: inherit;
|
||
background: transparent;
|
||
outline: none;
|
||
width: 100%;
|
||
border: none;
|
||
text-align: center;
|
||
cursor: text;
|
||
}
|
||
/* https://stackoverflow.com/a/4298216 */
|
||
/* Chrome */
|
||
#${input.id}::-webkit-outer-spin-button,
|
||
#${input.id}::-webkit-inner-spin-button {
|
||
-webkit-appearance: none;
|
||
margin: 0;
|
||
}
|
||
/* Firefox */
|
||
#${input.id}[type=number] {
|
||
-moz-appearance:textfield;
|
||
}
|
||
`)
|
||
|
||
const destroy = lodash.over(
|
||
fromEvent(input, 'keydown')
|
||
.pipe<KeyboardEvent>(debounceTime(200))
|
||
.subscribe(({ key }) => {
|
||
if (key !== 'Enter') {
|
||
return
|
||
}
|
||
|
||
const value = parseFloat(input.value)
|
||
|
||
try {
|
||
if (!lodash.isFinite(value)) {
|
||
throw new Error('无效的倍数值')
|
||
}
|
||
if (value < MIN_BROWSER_SPEED_VALUE) {
|
||
throw new Error('倍数值太小了')
|
||
}
|
||
if (value > MAX_BROWSER_SPEED_VALUE) {
|
||
throw new Error('倍数值太大了')
|
||
}
|
||
if (this.speedContext.getAvailableSpeedValues().includes(value)) {
|
||
throw new Error('不能重复添加已有的倍数值')
|
||
}
|
||
this.addSpeedValue(value)
|
||
} catch (error) {
|
||
logError(String(error), ERROR_MESSAGE_DURATION)
|
||
input.focus()
|
||
input.select()
|
||
}
|
||
}),
|
||
fromEvent(root, 'mouseenter').subscribe(() => {
|
||
input.style.display = 'inline'
|
||
icon.style.display = 'none'
|
||
updateRecommendedValue()
|
||
setTimeout(() => input.focus())
|
||
}),
|
||
fromEvent(root, 'mouseleave').subscribe(() => {
|
||
input.style.display = 'none'
|
||
icon.style.display = 'inline'
|
||
}),
|
||
() => root.remove(),
|
||
() => styleElement.remove(),
|
||
)
|
||
|
||
return {
|
||
node: root,
|
||
destroy,
|
||
}
|
||
}
|
||
|
||
createCustomSpeedMenuItemElement(
|
||
value: number,
|
||
): VNode<HTMLLIElement, number> {
|
||
// 3.X的倍速需要通过data-value读取值并设置
|
||
const { closeBtn, root } = $<{ closeBtn: HTMLElement }, HTMLLIElement>(`
|
||
<li class="${splitToSpace(trimLeadingDot(PLAYER_AGENT.custom.speedMenuItem.selector))} ${EXTEND_SPEED_ITEM_CLASS_NAME}" data-value="${(value)}">
|
||
${formatSpeedText(value)}
|
||
<i data-ref="close-btn" class="mdi mdi-close-circle"></i>
|
||
</li>
|
||
`)
|
||
|
||
const styleElement = addStyle(`
|
||
.${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"] {
|
||
color: inherit;
|
||
opacity: 0.5;
|
||
display: none;
|
||
position: absolute;
|
||
right: 4px;
|
||
}
|
||
:is(${PLAYER_AGENT.custom.speedMenuItem.selector}):not(${PLAYER_AGENT.custom.active.selector}):hover [data-ref="close-btn"] {
|
||
display: inline;
|
||
}
|
||
.${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"]:hover {
|
||
opacity: 1;
|
||
transition: all .3s;
|
||
}
|
||
`)
|
||
|
||
const destroy = lodash.over(
|
||
fromEvent(closeBtn, 'click').subscribe(() => {
|
||
this.removeSpeedValue(value)
|
||
}),
|
||
() => root.remove(),
|
||
() => styleElement.remove(),
|
||
)
|
||
|
||
return {
|
||
tag: value,
|
||
node: root,
|
||
destroy,
|
||
}
|
||
}
|
||
|
||
protected elementMap: VNode<HTMLLIElement>[] = [];
|
||
protected inputElement: VNode<HTMLLIElement>
|
||
protected unpatch: TeardownLogic
|
||
|
||
protected migrate() {
|
||
const { options } = this.settings
|
||
const { options: rememberSpeedOptions } = getComponentSettings('rememberVideoSpeed') as ComponentSettings<RememberSpeedOptions>
|
||
if (rememberSpeedOptions.extendList) {
|
||
options.extendSpeedList = Array.from(rememberSpeedOptions.extendList as number[])
|
||
delete rememberSpeedOptions.extendList
|
||
delete rememberSpeedOptions.extend
|
||
Toast.success('从「倍速记忆」组件迁移旧配置成功', '【扩展倍速】旧配置迁移完成', 8e3)
|
||
}
|
||
}
|
||
|
||
// eslint-disable-next-line class-methods-use-this
|
||
getSpeedContextMixin({ menuListElement }: SpeedContext): Partial<SpeedContext> {
|
||
return {
|
||
query: (speed: number) => des<HTMLElement>(
|
||
// 有的时候,选择 1.0x 倍速,会错误选中 11.0x 倍速
|
||
// 删掉 11.0x 倍速后,再选择 1.0x 倍速就没出现该问题,这是因为之前的实现使用 contains 函数判断,由于 11.0x 倍速文本包含 1.0x,所以被误判了
|
||
// 原先使用 contains 而不直接比较的原因是,自定义的倍速菜单项,可能在创建时引入了多余的空白符,现在使用 normalize-space 代替之前的做法
|
||
// `./*[contains(@class, "${ExtendSpeedComponent.speedMenuItemClassName}")`
|
||
`./*[(${ExtendSpeedComponent.speedMenuItemClassName.split(',').map(cls => `contains(@class, "${cls}")`).join(' or ')})`
|
||
+ ` and not(contains(@class, "${EXTEND_SPEED_INPUT_CLASS_NAME}"))`
|
||
// see: https://developer.mozilla.org/en-US/docs/Web/XPath/Functions/normalize-space
|
||
// 自定义的倍速菜单项,在创建时引入了多余的空白符,需要通过 normalize-space 函数排除掉
|
||
+ ` and normalize-space()="${formatSpeedText(speed)}"]`,
|
||
menuListElement,
|
||
),
|
||
}
|
||
}
|
||
|
||
currentSpeedValue: number
|
||
|
||
onSpeedContext({
|
||
menuListElementClickSpeedChange$,
|
||
menuListElementMutations$,
|
||
playbackRate$,
|
||
menuListElement,
|
||
}: SpeedContext) {
|
||
this.options.extendSpeedList$
|
||
.subscribe({
|
||
next: extendSpeedList => this.patch(
|
||
diff(
|
||
this.elementMap.map(e => e.tag), Array.from(extendSpeedList),
|
||
),
|
||
),
|
||
complete: () => {
|
||
this.unpatch()
|
||
},
|
||
})
|
||
|
||
// 倍速菜单最大高度
|
||
this.options.maxMenuHeight$
|
||
.subscribe(
|
||
loadStyle({
|
||
name: 'extend-video-speed-style',
|
||
style: maxMenuHeight => `
|
||
${PLAYER_AGENT.custom.speedMenuList.selector} {
|
||
display: flex !important; /* 防止3.X样式覆盖 */
|
||
flex-direction: column;
|
||
justify-content: center; /* 添加倍速那一项,当鼠标在上半部分会有莫名其妙的'mouseleave', 用这个修复 */
|
||
overflow-y: auto;
|
||
max-height: ${maxMenuHeight}px;
|
||
visibility: hidden;
|
||
}
|
||
/* 修复2.X倍速列表显示问题 */
|
||
:is(${PLAYER_AGENT.custom.show.selector}) :is(${PLAYER_AGENT.custom.speedMenuList.selector}){
|
||
visibility: visible;
|
||
}
|
||
`,
|
||
}),
|
||
)
|
||
|
||
this.options.hideScrollbar$
|
||
.subscribe(
|
||
loadStyle({
|
||
name: 'extend-video-speed-no-scrollbar-style',
|
||
style: hideScrollbar => hideScrollbar && `
|
||
${PLAYER_AGENT.custom.speedMenuList.selector} {
|
||
scrollbar-width: none !important;
|
||
overscroll-behavior: contain;
|
||
}
|
||
:is(${PLAYER_AGENT.custom.speedMenuList.selector})::-webkit-scrollbar {
|
||
height: 0 !important;
|
||
width: 0 !important;
|
||
}`,
|
||
}),
|
||
)
|
||
|
||
let nativeSpeedValue = 1
|
||
|
||
playbackRate$
|
||
.pipe(filter(value => NATIVE_SUPPORTED_VALUES.includes(value)))
|
||
.subscribe(value => {
|
||
nativeSpeedValue = value
|
||
})
|
||
|
||
menuListElementClickSpeedChange$.subscribe({
|
||
next: value => {
|
||
this.forceVideoSpeedWithUpdateStyle(value)
|
||
this.currentSpeedValue = value
|
||
},
|
||
complete: () => {
|
||
// 并不能指望 setVideoSpeed 正常工作,但是模拟点击仍然可能引发一些副作用,所以还是需要调用它...
|
||
this.setVideoSpeed(nativeSpeedValue)
|
||
this.forceVideoSpeedWithUpdateStyle(nativeSpeedValue)
|
||
},
|
||
})
|
||
|
||
// 【修复】番剧类视频扩展倍速菜单项顺序可能错误
|
||
//
|
||
// 针对番剧类视频的倍速菜单做后备的 flex 布局设置方式
|
||
// 番剧类视频的倍速菜单通过内联样式方式直接改变 menuListElement 的 display
|
||
// 这将优先于通过内部样式设定的 flex 布局,因此只能监听元素的特性变化,在 display 被设置为 block 时,强行设置为 flex
|
||
menuListElementMutations$.subscribe(({ attributes }) => {
|
||
attributes.forEach(mutation => {
|
||
if (mutation.attributeName === 'style') {
|
||
const { display } = unsafeWindow.getComputedStyle(menuListElement)
|
||
if (display === 'block') {
|
||
menuListElement.style.display = 'flex'
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
this.currentSpeedValue && requestIdleCallback(() => {
|
||
this.setVideoSpeed(this.currentSpeedValue, 1000)
|
||
})
|
||
}
|
||
|
||
protected async forceVideoSpeedWithUpdateStyle(value: number) {
|
||
await this.forceVideoSpeed(value)
|
||
// 番剧类的视频的激活 class 居然是按子元素顺序修改的,只能步其后尘再强制改一次了
|
||
setTimeout(() => this.forceUpdateStyle(value))
|
||
}
|
||
|
||
protected readonly filterNativeSpeed = () => ({
|
||
subscribe,
|
||
next,
|
||
}: PublishContext<number>) => {
|
||
subscribe(currentSpeed => {
|
||
if (NATIVE_SUPPORTED_VALUES.includes(currentSpeed)) {
|
||
next(currentSpeed)
|
||
}
|
||
})
|
||
};
|
||
|
||
protected patch(params: [number, number, number[]]) {
|
||
const [start, deleteCount, added] = params
|
||
const { menuListElement } = this.speedContext
|
||
if (!this.inputElement) {
|
||
this.inputElement = this.createInputElement()
|
||
menuListElement.prepend(this.inputElement.node)
|
||
}
|
||
|
||
if (deleteCount === 0 && added.length === 0) {
|
||
return
|
||
}
|
||
|
||
const addedElements = added.map(v => this.createCustomSpeedMenuItemElement(v))
|
||
|
||
const deleted = this.elementMap.splice(
|
||
start,
|
||
deleteCount,
|
||
...addedElements,
|
||
)
|
||
|
||
deleted.forEach(vnode => {
|
||
vnode.destroy()
|
||
});
|
||
|
||
((this.elementMap[start - 1] || this.inputElement).node)
|
||
.after(...addedElements.map(el => el.node).reverse())
|
||
|
||
// 为所有倍速菜单项刷新 Order
|
||
menuListElement
|
||
.querySelectorAll(
|
||
`:is(${PLAYER_AGENT.custom.speedMenuItem.selector}):not(#${this.inputElement.node.id})`,
|
||
)
|
||
.forEach((it: HTMLLIElement) => {
|
||
it.style.order = calcOrder(
|
||
parseSpeedText(it.innerHTML),
|
||
)
|
||
})
|
||
|
||
this.unpatch = () => {
|
||
this.inputElement.destroy()
|
||
this.inputElement = undefined
|
||
this.elementMap.forEach(vnode => vnode.destroy())
|
||
this.elementMap.length = 0
|
||
}
|
||
}
|
||
|
||
protected forceUpdateStyle(value: number) {
|
||
const {
|
||
menuListElement,
|
||
containerElement,
|
||
nameBtnElement,
|
||
query: querySpeedMenuItemElement,
|
||
} = this.speedContext
|
||
// 移除所有激活态的菜单项
|
||
for (const element of dea(
|
||
`./*[(${trimLeadingDot(ExtendSpeedComponent.speedMenuItemClassName).split(',').map(cls => `contains(@class, "${cls}")`).join(' or ')})`
|
||
+ ' and '
|
||
+ `(${trimLeadingDot(ExtendSpeedComponent.activeClassName).split(',').map(cls => `contains(@class, "${cls}")`).join(' or ')})]`,
|
||
menuListElement,
|
||
) as Iterable<HTMLElement>) {
|
||
element.classList.remove(...ExtendSpeedComponent.activeClassName.split(','))
|
||
}
|
||
// 对于被强制更新的菜单项,添加激活态的类名
|
||
querySpeedMenuItemElement(value).classList.add(...ExtendSpeedComponent.activeClassName.split(','))
|
||
// 关闭菜单
|
||
containerElement.classList.remove(...ExtendSpeedComponent.showClassName.split(','))
|
||
// 更新倍速菜单按钮文本
|
||
nameBtnElement.innerText = formatSpeedText(
|
||
value,
|
||
true,
|
||
)
|
||
}
|
||
}
|