mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
import { playerAgent } from '@/components/video/player-agent'
|
|
import { LifeCycleEventTypes } from '@/core/life-cycle'
|
|
import { videoChange } from '@/core/observer'
|
|
import { des } from '@/core/utils'
|
|
import { ascendingSort } from '@/core/utils/sort'
|
|
import {
|
|
bindCallback, firstValueFrom, fromEvent, subject, Subject,
|
|
} from '../mini-rxjs'
|
|
import { bufferSet } from '../mini-rxjs/operators/bufferSet'
|
|
import { combineLatest } from '../mini-rxjs/operators/combineLatest'
|
|
import { debounceTime } from '../mini-rxjs/operators/debounceTime'
|
|
import { distinctUntilChanged } from '../mini-rxjs/operators/distinctUntilChanged'
|
|
import { filter } from '../mini-rxjs/operators/filter'
|
|
import { map } from '../mini-rxjs/operators/map'
|
|
import { pairwise } from '../mini-rxjs/operators/pairwise'
|
|
import { startWith } from '../mini-rxjs/operators/startWith'
|
|
import type { EntrySpeedComponent } from '../speed'
|
|
import {
|
|
formatSpeedText, parseSpeedText, trimLeadingDot, useShare,
|
|
} from './utils'
|
|
|
|
export const PLAYER_AGENT = playerAgent.provideCustomQuery({
|
|
video: {
|
|
speedMenuList: '.bilibili-player-video-btn-speed-menu,.bpx-player-ctrl-playbackrate-menu',
|
|
speedMenuItem: '.bilibili-player-video-btn-speed-menu-list,.bpx-player-ctrl-playbackrate-menu-item',
|
|
speedNameBtn: '.bilibili-player-video-btn-speed-name,.bpx-player-ctrl-playbackrate-result',
|
|
speedContainer: '.bilibili-player-video-btn-speed,.bpx-player-ctrl-playbackrate',
|
|
active: '.bilibili-player-active,.bpx-state-active',
|
|
show: '.bilibili-player-speed-show,.bpx-state-show',
|
|
},
|
|
bangumi: {
|
|
speedMenuList: '.squirtle-speed-select-list',
|
|
speedMenuItem: '.squirtle-select-item',
|
|
speedNameBtn: '.squirtle-speed-select-result',
|
|
speedContainer: '.squirtle-speed-wrap',
|
|
active: '.active',
|
|
// bangumi 那边没有这种 class, 随便填一个就行了
|
|
show: '.bilibili-player-speed-show',
|
|
},
|
|
})
|
|
|
|
export type VideoIdObject = { aid: string; cid: string }
|
|
|
|
export enum SpeedSeekPosition {
|
|
MIN,
|
|
CURRENT,
|
|
MAX,
|
|
}
|
|
|
|
export interface SpeedContext {
|
|
readonly videoChange$: Subject<VideoIdObject>
|
|
readonly speedContext$: Subject<SpeedContext>
|
|
readonly videoIdObject: VideoIdObject
|
|
readonly containerElement: HTMLElement
|
|
readonly videoElement: HTMLVideoElement
|
|
readonly nameBtnElement: HTMLElement
|
|
readonly menuListElement: HTMLElement
|
|
readonly activeVideoSpeed$: Subject<number>
|
|
readonly playbackRate$: Subject<number>
|
|
readonly playbackRateChange$: Subject<number>
|
|
readonly menuListElementClickSpeed$: Subject<number>
|
|
readonly menuListElementClickSpeedChange$: Subject<number>
|
|
readonly videoSpeedChange$: Subject<number>
|
|
readonly menuListElementMutations$: Subject<
|
|
{ attributes: MutationRecord[], childList: MutationRecord[] }
|
|
>
|
|
|
|
getAvailableSpeedValues(): number[]
|
|
getActiveVideoSpeed(): number
|
|
getOldActiveVideoSpeed(): number | undefined
|
|
getOldPlaybackRate(): number | undefined
|
|
|
|
query(speed: number): HTMLElement | null
|
|
/**
|
|
* 通过模拟点击倍速菜单项来设置倍速
|
|
*
|
|
* 若指定的倍速值不在倍速菜单里,或等待倍速菜单就绪后,最终设定的倍速值与指定的倍速值不相符,则会抛出异常
|
|
*
|
|
* @param value 欲设置的倍速值
|
|
*/
|
|
set(value: number, timeout?: number): Promise<void>
|
|
/**
|
|
* 强行设置倍速
|
|
*
|
|
* 通过 {@link set} 或 {@link toggle} 方法设置的倍速,可能会因为指定的倍速值不在倍速菜单里而失败,如果想要强行设置倍速值,就需要使用此方法
|
|
*
|
|
* 【注意】谨慎使用此方法,这个方法不会更新倍速菜单的状态,有可能造成状态不一致的现象,而且还可能会影响上次倍速值,从而出现一些其他的副作用
|
|
*/
|
|
force(value: number): Promise<void>
|
|
/** 重置倍速为 1.0x */
|
|
reset(): Promise<void>
|
|
/**
|
|
* 切换倍速
|
|
*
|
|
* 默认情况下,如果当前倍速不是 1.0x,则切换到 1.0x(即重置当前视频倍速),否则切换到上次倍速
|
|
*
|
|
* @param legacy 传统方式:无论当前倍速如何,均切换到上次倍速
|
|
*/
|
|
toggle(legacy?: boolean): Promise<void>
|
|
/**
|
|
* 切换倍速
|
|
*
|
|
* 类似文件系统 API 提供的 `seek` 函数,将倍速菜单被视作可寻址的地址空间,想象一个「指针」指向当前激活的倍速菜单项,
|
|
* 「指针」每次移动后所指向的倍速菜单项就成为新的被激活的倍速菜单项,通过给定参照和偏移量移动「指针」,就完成了切换倍速的操作.
|
|
*
|
|
* @param offset 「指针」偏移量
|
|
* @param whence 可选,默认为当前倍速菜单项,给 offset 一个参照,表示从何处开始偏移量的计算
|
|
*/
|
|
toggle(offset: number, whence?: SpeedSeekPosition): Promise<void>
|
|
/**
|
|
* 提高倍速
|
|
*
|
|
* 类似于:
|
|
*
|
|
* ```
|
|
* toggle(1, SpeedSeekPosition.CURRENT)
|
|
* ```
|
|
*
|
|
* 与之不同的是,如果当前倍速已经是倍速菜单里最高的倍速,不会产生作用,也不会抛出异常
|
|
*/
|
|
increase(): Promise<void>
|
|
/**
|
|
* 降低倍速
|
|
*
|
|
* 类似于:
|
|
*
|
|
* ```
|
|
* toggle(-1, SpeedSeekPosition.CURRENT)
|
|
* ```
|
|
*
|
|
* 与之不同的是,如果当前倍速已经是倍速菜单里最低的倍速,不会产生作用,也不会抛出异常
|
|
*/
|
|
decrease(): Promise<void>
|
|
}
|
|
|
|
export interface DisposableSpeedContext extends SpeedContext {
|
|
dispose(): void
|
|
}
|
|
|
|
const useMutationObserver = (
|
|
target: Node,
|
|
mutationInit: MutationObserverInit,
|
|
callback: MutationCallback,
|
|
) => {
|
|
const observer = new MutationObserver(callback)
|
|
observer.observe(target, mutationInit)
|
|
return observer
|
|
}
|
|
|
|
const buildElementPart = (
|
|
[containerElement, videoElement]:
|
|
[HTMLElement | null, HTMLVideoElement | null],
|
|
) => {
|
|
if (!containerElement) {
|
|
throw new Error('speed container element not found!')
|
|
}
|
|
if (!videoElement) {
|
|
throw new Error('video element not found!')
|
|
}
|
|
|
|
const nameBtnElement = containerElement.querySelector(
|
|
PLAYER_AGENT.custom.speedNameBtn.selector,
|
|
) as HTMLButtonElement
|
|
const menuListElement = containerElement.querySelector(
|
|
PLAYER_AGENT.custom.speedMenuList.selector,
|
|
) as HTMLElement
|
|
|
|
const query = (speed: number) => des<HTMLElement | null>(
|
|
// 有的时候,选择 1.0x 倍速,会错误选中 11.0x 倍速
|
|
// 删掉 11.0x 倍速后,再选择 1.0x 倍速就没出现该问题,这是因为之前的实现使用 contains 函数判断,由于 11.0x 倍速文本包含 1.0x,所以被误判了
|
|
// 原先使用 contains 而不直接比较的原因是,自定义的倍速菜单项,可能在创建时引入了多余的空白符,现在使用 normalize-space 代替之前的做法
|
|
`./*[contains(@class, "${trimLeadingDot(PLAYER_AGENT.custom.speedMenuItem.selector)}")`
|
|
// see: https://developer.mozilla.org/en-US/docs/Web/XPath/Functions/normalize-space
|
|
// 自定义的倍速菜单项,在创建时引入了多余的空白符,需要通过 normalize-space 函数排除掉
|
|
+ ` and normalize-space()="${formatSpeedText(speed)}"]`,
|
|
menuListElement,
|
|
)
|
|
|
|
let activeVideoSpeed: number
|
|
let oldActiveVideoSpeed: number
|
|
let availableSpeedValues: number[]
|
|
const menuListElementMutations$ = subject()
|
|
const activeVideoSpeed$ = subject<number>().pipe(distinctUntilChanged())
|
|
|
|
activeVideoSpeed$.pipe<[number, number]>(
|
|
startWith(undefined),
|
|
pairwise(),
|
|
).subscribe(([oldValue, newValue]) => {
|
|
oldActiveVideoSpeed = oldValue
|
|
activeVideoSpeed = newValue
|
|
})
|
|
|
|
const updateActiveVideoSpeed = (target?: HTMLElement | CharacterData) => {
|
|
if (!target) { return }
|
|
switch (target.nodeType) {
|
|
case Node.TEXT_NODE:
|
|
activeVideoSpeed$.next(parseSpeedText((target as CharacterData).data))
|
|
break
|
|
case Node.ELEMENT_NODE:
|
|
activeVideoSpeed$.next(parseSpeedText((target as HTMLElement).innerHTML))
|
|
break
|
|
default:
|
|
console.warn('The target parameter of updateActiveVideoSpeed must be a Node, and the node type must be one of TEXT_NODE and ELEMENT_NODE')
|
|
break
|
|
}
|
|
}
|
|
|
|
const updateAvailableSpeedValues = () => {
|
|
availableSpeedValues = lodash([...menuListElement.children])
|
|
.map(element => lodash.attempt(() => parseSpeedText(element.textContent)))
|
|
.reject(speed => lodash.isError(speed))
|
|
.sort(ascendingSort())
|
|
.value() as number[]
|
|
}
|
|
|
|
// 初始更新
|
|
updateActiveVideoSpeed(nameBtnElement)
|
|
updateAvailableSpeedValues()
|
|
|
|
const menuListElementObserver = useMutationObserver(
|
|
menuListElement,
|
|
{ childList: true, attributes: true },
|
|
mutations => {
|
|
const { attributes = [], childList = [] } = lodash.groupBy(mutations, 'type')
|
|
if (childList.length) {
|
|
updateAvailableSpeedValues()
|
|
}
|
|
menuListElementMutations$.next({ attributes, childList })
|
|
},
|
|
)
|
|
|
|
const nameBtnElementObserver = useMutationObserver(
|
|
nameBtnElement,
|
|
{ childList: true, subtree: true },
|
|
mutations => {
|
|
mutations.forEach(mutation => {
|
|
const [newTextNode] = mutation.addedNodes
|
|
updateActiveVideoSpeed(newTextNode as (HTMLElement | CharacterData))
|
|
})
|
|
},
|
|
)
|
|
|
|
const dispose = () => {
|
|
menuListElementObserver.disconnect()
|
|
nameBtnElementObserver.disconnect()
|
|
}
|
|
|
|
return {
|
|
containerElement,
|
|
videoElement,
|
|
nameBtnElement,
|
|
menuListElement,
|
|
query,
|
|
dispose,
|
|
activeVideoSpeed$,
|
|
menuListElementMutations$,
|
|
getActiveVideoSpeed: () => activeVideoSpeed,
|
|
getOldActiveVideoSpeed: () => oldActiveVideoSpeed,
|
|
getAvailableSpeedValues: () => availableSpeedValues,
|
|
}
|
|
}
|
|
|
|
const buildSubjectPart = (
|
|
elementContext: ReturnType<typeof buildElementPart>,
|
|
) => {
|
|
const { videoElement, menuListElement } = elementContext
|
|
|
|
const menuListElementClickSpeed$ = fromEvent(
|
|
menuListElement,
|
|
'click',
|
|
).pipe<number>(
|
|
map(ev => {
|
|
const { innerText, innerHTML } = ev.target as HTMLLIElement
|
|
const speedText = innerText.trim() || innerHTML.trim()
|
|
return lodash.attempt(() => parseSpeedText(speedText))
|
|
}),
|
|
filter(value => !lodash.isError(value)),
|
|
)
|
|
const playbackRate$ = subject(({ next }) => {
|
|
// 沿着 videoElement 的原型链一路向上找,直到找到 hasOwnProperty playbackRate 的 prototype 或者 null
|
|
let proto: { playbackRate: number } = videoElement
|
|
|
|
do {
|
|
proto = Object.getPrototypeOf(proto)
|
|
} while (
|
|
proto === null
|
|
|| !Object.prototype.hasOwnProperty.call(proto, 'playbackRate')
|
|
)
|
|
|
|
const descriptor = Object.getOwnPropertyDescriptor(proto, 'playbackRate')
|
|
|
|
Object.defineProperty(proto, 'playbackRate', {
|
|
set(v) {
|
|
descriptor.set.call(this, v)
|
|
next(v)
|
|
},
|
|
})
|
|
|
|
return () => {
|
|
Object.defineProperty(proto, 'playbackRate', descriptor)
|
|
}
|
|
})
|
|
const menuListElementClickSpeedChange$ = menuListElementClickSpeed$.pipe(distinctUntilChanged())
|
|
const playbackRateChange$ = playbackRate$.pipe(distinctUntilChanged())
|
|
|
|
const videoSpeedChange$ = subject(({ next }) => {
|
|
const temp$ = combineLatest(
|
|
menuListElementClickSpeedChange$,
|
|
playbackRateChange$,
|
|
)
|
|
temp$.subscribe(([userSpeed, currentSpeed]) => {
|
|
if (userSpeed === currentSpeed) {
|
|
next(currentSpeed)
|
|
}
|
|
})
|
|
return () => temp$.complete()
|
|
}).pipe(distinctUntilChanged())
|
|
|
|
let oldPlaybackRate: number
|
|
|
|
playbackRateChange$
|
|
.pipe<[number, number]>(
|
|
debounceTime(200),
|
|
startWith(undefined),
|
|
pairwise(),
|
|
)
|
|
.subscribe(([oldValue]) => {
|
|
oldPlaybackRate = oldValue
|
|
})
|
|
|
|
const subjects = {
|
|
menuListElementClickSpeed$,
|
|
menuListElementClickSpeedChange$,
|
|
playbackRate$,
|
|
playbackRateChange$,
|
|
videoSpeedChange$,
|
|
}
|
|
|
|
const dispose = () => {
|
|
lodash.values(subjects).forEach(subject$ => {
|
|
subject$.complete()
|
|
})
|
|
elementContext.dispose()
|
|
}
|
|
|
|
return {
|
|
...elementContext,
|
|
...subjects,
|
|
dispose,
|
|
getOldPlaybackRate: () => oldPlaybackRate,
|
|
}
|
|
}
|
|
|
|
export const [NoSuchSpeedMenuItemElementError] = useShare('speed.NoSuchSpeedMenuItemElementError', () => class InnerNoSuchSpeedMenuItemElementError extends Error {
|
|
readonly formattedSpeed: string
|
|
constructor(readonly speed: number) {
|
|
const formattedSpeedMaybeError = lodash.attempt(() => formatSpeedText(speed))
|
|
const formattedSpeed = lodash.isError(formattedSpeedMaybeError)
|
|
? String(speed)
|
|
: String(formattedSpeedMaybeError)
|
|
super(`There is no such speed menu item as ${formattedSpeed}`)
|
|
this.formattedSpeed = formattedSpeed
|
|
}
|
|
})
|
|
|
|
const buildMethodPart = (speedContext: ReturnType<typeof buildSubjectPart>) => {
|
|
const {
|
|
query,
|
|
videoElement,
|
|
videoSpeedChange$,
|
|
getOldActiveVideoSpeed,
|
|
getAvailableSpeedValues,
|
|
getActiveVideoSpeed,
|
|
} = speedContext
|
|
|
|
const set = async (value: number, timeoutArg = 200) => {
|
|
const speedMenuItemElement = query(value)
|
|
|
|
if (speedMenuItemElement == null) {
|
|
throw new NoSuchSpeedMenuItemElementError(value)
|
|
}
|
|
|
|
speedMenuItemElement.click()
|
|
|
|
const check = result => {
|
|
if ((result ?? videoElement.playbackRate) !== value) {
|
|
throw new Error(`failed to set ${formatSpeedText(value)} video speed.`)
|
|
}
|
|
}
|
|
|
|
const promises = [
|
|
firstValueFrom(
|
|
videoSpeedChange$.pipe(debounceTime(Math.max(0, timeoutArg || 0))),
|
|
),
|
|
]
|
|
|
|
if (timeoutArg > 0) {
|
|
promises.push(new Promise(
|
|
(resolve, reject) => setTimeout(
|
|
() => setTimeout(reject, timeoutArg),
|
|
),
|
|
))
|
|
}
|
|
|
|
await Promise.all(promises)
|
|
.then(check)
|
|
.catch(check)
|
|
}
|
|
|
|
const force = async (value: number) => {
|
|
videoElement.playbackRate = value
|
|
}
|
|
|
|
const reset = async () => {
|
|
await set(1)
|
|
}
|
|
|
|
const toggle = async (legacyOrOffset?: boolean | number, whence?: SpeedSeekPosition) => {
|
|
if (lodash.isNil(legacyOrOffset)) {
|
|
legacyOrOffset = false
|
|
}
|
|
|
|
if (typeof legacyOrOffset === 'boolean') {
|
|
if (!legacyOrOffset && videoElement.playbackRate !== 1) {
|
|
await reset()
|
|
} else {
|
|
await set(getOldActiveVideoSpeed())
|
|
}
|
|
} else {
|
|
const availableSpeedValues = getAvailableSpeedValues()
|
|
switch (whence) {
|
|
case SpeedSeekPosition.MIN:
|
|
await set(availableSpeedValues[legacyOrOffset])
|
|
break
|
|
case SpeedSeekPosition.MAX:
|
|
await set(
|
|
availableSpeedValues[
|
|
availableSpeedValues.length - 1 + legacyOrOffset
|
|
],
|
|
)
|
|
break
|
|
case SpeedSeekPosition.CURRENT:
|
|
default: {
|
|
const index = availableSpeedValues.indexOf(getActiveVideoSpeed())
|
|
if (index === -1) {
|
|
throw new Error('Unexpected Error: The available speed values do not include the active speed value, this should be a bug, please report the issue on github!')
|
|
}
|
|
await set(availableSpeedValues[index + legacyOrOffset])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const step = async (direction: number) => {
|
|
try {
|
|
await toggle(direction, SpeedSeekPosition.CURRENT)
|
|
} catch (error) {
|
|
console.warn(error)
|
|
if (!(error instanceof NoSuchSpeedMenuItemElementError)) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
const increase = async () => {
|
|
await step(1)
|
|
}
|
|
|
|
const decrease = async () => {
|
|
await step(-1)
|
|
}
|
|
|
|
return Object.assign(speedContext, {
|
|
set,
|
|
force,
|
|
reset,
|
|
toggle,
|
|
step,
|
|
increase,
|
|
decrease,
|
|
})
|
|
}
|
|
|
|
const useShareSpeedContext = () => useShare<DisposableSpeedContext>('speed.speedContext')
|
|
|
|
export const useShareBuildArgument$ = () => useShare<Subject<unknown>>(
|
|
'speed.buildArguments$',
|
|
() => subject()
|
|
.pipe(
|
|
bufferSet((component: EntrySpeedComponent) => component.settings.enabled),
|
|
),
|
|
)
|
|
|
|
export const getSpeedContext = async (
|
|
build: (args) => (context: DisposableSpeedContext) => DisposableSpeedContext = lodash.identity,
|
|
) => {
|
|
const [speedContext, setSpeedContext] = useShareSpeedContext()
|
|
|
|
if (speedContext) {
|
|
return speedContext
|
|
}
|
|
|
|
let resolveBuildPromise
|
|
let rejectBuildPromise
|
|
|
|
const [lifeCycleComponentLoaded$] = useShare('lifeCycleComponentLoaded$', () => fromEvent(
|
|
unsafeWindow, LifeCycleEventTypes.ComponentsLoaded,
|
|
))
|
|
const [shareBuildArgument$] = useShareBuildArgument$()
|
|
const [videoChange$] = useShare('speed.videoChange$', () => bindCallback<VideoIdObject>(videoChange).pipe(filter(({ aid, cid }) => aid || cid)))
|
|
const [speedContext$] = useShare('speed.speedContext$', () => subject<DisposableSpeedContext>(
|
|
({ next }) => combineLatest(
|
|
videoChange$,
|
|
shareBuildArgument$,
|
|
lifeCycleComponentLoaded$,
|
|
)
|
|
.subscribe(
|
|
([videoIdObject, shareBuildArgument]) => {
|
|
const [oldSpeedContext] = useShareSpeedContext()
|
|
oldSpeedContext?.dispose()
|
|
rejectBuildPromise?.('context update')
|
|
|
|
const buildPromise = new Promise((resolve, reject) => {
|
|
resolveBuildPromise = resolve
|
|
rejectBuildPromise = reject
|
|
})
|
|
|
|
Promise.all([
|
|
Promise
|
|
.all([
|
|
PLAYER_AGENT.custom.speedContainer() as Promise<HTMLElement | null>,
|
|
PLAYER_AGENT.query.video.element() as Promise<HTMLVideoElement | null>,
|
|
])
|
|
.then(resolveBuildPromise),
|
|
buildPromise,
|
|
])
|
|
.then(([, elements]) => elements)
|
|
.then(buildElementPart)
|
|
.then(buildSubjectPart)
|
|
.then(buildMethodPart)
|
|
.then(context => (Object.assign(context, {
|
|
videoIdObject,
|
|
speedContext$,
|
|
videoChange$,
|
|
})))
|
|
.then(build(shareBuildArgument))
|
|
.then(next)
|
|
.catch(err => console.error(err))
|
|
},
|
|
),
|
|
))
|
|
|
|
speedContext$.subscribe(setSpeedContext)
|
|
|
|
return firstValueFrom(speedContext$)
|
|
}
|