diff --git a/doc/features/features.md b/doc/features/features.md index 2763c2b37..cc7335de1 100644 --- a/doc/features/features.md +++ b/doc/features/features.md @@ -770,7 +770,7 @@ by [@FoundTheWOUT](https://github.com/FoundTheWOUT) 在视频播放器网页全屏时, 即使宽度过小也强制保留弹幕发送栏, 注意这可能导致右侧的功能按钮挤出边界. -### [倍速增强](../../registry/dist/components/video/player/remember-speed.js) +### [记忆倍速](../../registry/dist/components/video/player/remember-speed.js) `rememberVideoSpeed` **jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/player/remember-speed.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/video/player/remember-speed.js) @@ -779,7 +779,18 @@ by [@FoundTheWOUT](https://github.com/FoundTheWOUT) by [@JLoeve](https://github.com/LonelySteve) -可以记忆上次选择的视频播放速度, 还可以使用更多倍速来扩展原生倍速菜单. +提高视频播放器的倍速记忆体验,可实现跨页共享倍速,也可以按视频分别记忆倍速. + +### [扩展倍速](../../registry/dist/components/video/player/extend-speed.js) +`extendVideoSpeed` + +**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/player/extend-speed.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/video/player/extend-speed.js) + +**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/player/extend-speed.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/player/extend-speed.js) + +by [@JLoeve](https://github.com/LonelySteve) + +扩展视频播放器的倍速菜单项,可用于突破原有播放倍数的上限或下限. ### [删除视频弹窗](../../registry/dist/components/video/player/remove-popup.js) `removePlayerPopup` @@ -959,4 +970,15 @@ by [@wuliic](https://github.com/wullic) by [@diannaojiang](https://github.com/diannaojiang) -为下载视频增加 MPV 输出支持, 配置方式请参考 [Bilibili-Playin-Mpv](https://github.com/diannaojiang/Bilibili-Playin-Mpv) \ No newline at end of file +为下载视频增加 MPV 输出支持, 配置方式请参考 [Bilibili-Playin-Mpv](https://github.com/diannaojiang/Bilibili-Playin-Mpv) + +### [视频倍速 - 快捷键支持](../../registry/dist/plugins/video/player/speed.js) +`speed.keymap` + +**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/player/speed.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/video/player/speed.js) + +**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/video/player/speed.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/video/player/speed.js) + +by [@JLoeve](https://github.com/LonelySteve) + +为操作视频倍速提供快捷键支持,同时适配「扩展倍速」,联动「记忆倍速」并提供「清除倍速记忆」功能. diff --git a/registry/lib/components/video/player/common/mini-rxjs/create.ts b/registry/lib/components/video/player/common/mini-rxjs/create.ts new file mode 100644 index 000000000..b69dbfb0b --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/create.ts @@ -0,0 +1,24 @@ +import { subject } from './subject' + +export const of = (...items: any[]) => subject(({ next, complete }) => { + items.forEach(item => { next(item) }) + complete() +}) + +export const fromEvent = (element: EventTarget, eventName: string) => subject(({ next }) => { + element.addEventListener(eventName, next) + return () => element.removeEventListener(eventName, next) +}) + +export const fromPromise = (promise: Promise) => subject(({ next, complete, error }) => { + promise + .then(next) + .catch(error) + .finally(complete) +}) + +export const bindCallback = (cb: (...args: any[]) => any, ...args_: any[]) => subject( + ({ next }) => { + cb(...args_, next) + }, +) diff --git a/registry/lib/components/video/player/common/mini-rxjs/index.ts b/registry/lib/components/video/player/common/mini-rxjs/index.ts new file mode 100644 index 000000000..13da6a1d2 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/index.ts @@ -0,0 +1,3 @@ +export * from './create' +export * from './subject' +export * from './utils' diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/bufferSet.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/bufferSet.ts new file mode 100644 index 000000000..d5438b98e --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/bufferSet.ts @@ -0,0 +1,17 @@ +import { PublishContext } from '../subject' + +export const bufferSet = (predicate: (value: T) => boolean) => ( + { subscribe, next }: PublishContext, +) => { + const set = new Set() + subscribe(value => { + const oldSize = set.size + if (predicate(value)) { + set.add(value) + } else { + set.delete(value) + } + set.size !== oldSize && next([...set]) + }) + return () => { set.clear() } +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/combineLatest.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/combineLatest.ts new file mode 100644 index 000000000..c085fe1db --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/combineLatest.ts @@ -0,0 +1,33 @@ +import { subject, Subject } from '../subject' +import { asapScheduler, observeOn } from './observeOn' +import { withTeardownLogic } from './util' + +export const combineLatest = (...input: Subject[]) => subject( + ({ next, error, complete }) => withTeardownLogic(teardown => { + const buffer = [] + let completedCount = 0 + + teardown( + input.map((s, i) => s.pipe(observeOn(asapScheduler)).subscribe({ + next: value => { + buffer[i] = value + if (buffer.reduce(a => a + 1, 0) === input.length) { + next(buffer.slice()) + } + }, + complete: () => { + completedCount++ + if (completedCount === input.length) { + complete() + } + }, + error, + })), + ) + + teardown(() => { + buffer.length = 0 + completedCount = 0 + }) + }), +) diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/debounceTime.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/debounceTime.ts new file mode 100644 index 000000000..bbc51cb2b --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/debounceTime.ts @@ -0,0 +1,17 @@ +import { PublishContext } from '../subject' + +export const debounceTime = (wait: number) => ({ + subscribe, + next, + error, +}: PublishContext) => { + subscribe( + lodash.debounce(value => { + try { + next(value) + } catch (err) { + error(err) + } + }, wait), + ) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/distinctUntilChanged.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/distinctUntilChanged.ts new file mode 100644 index 000000000..fde189287 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/distinctUntilChanged.ts @@ -0,0 +1,17 @@ +import { PublishContext } from '../subject' + +export const distinctUntilChanged = () => ({ + subscribe, + next, +}: PublishContext) => { + let firstVisited = true + let previous + + subscribe(value => { + if (firstVisited || previous !== value) { + firstVisited = false + previous = value + next(value) + } + }) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/filter.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/filter.ts new file mode 100644 index 000000000..9ce3a3db3 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/filter.ts @@ -0,0 +1,10 @@ +import { PublishContext } from '../subject' + +export const filter = (predicate: (value: T) => boolean) => ({ + subscribe, + next, +}: PublishContext) => { + subscribe(value => { + predicate(value) && next(value) + }) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/map.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/map.ts new file mode 100644 index 000000000..f82189711 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/map.ts @@ -0,0 +1,10 @@ +import { PublishContext } from '../subject' + +export const map = (mapper: (value: T) => R) => ({ + subscribe, + next, +}: PublishContext) => { + subscribe(value => { + next(mapper(value)) + }) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/observeOn.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/observeOn.ts new file mode 100644 index 000000000..02df7e528 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/observeOn.ts @@ -0,0 +1,15 @@ +import { PublishContext } from '../subject' + +export const observeOn = scheduler => ({ + subscribe, next, complete, error, +}: PublishContext) => { + subscribe(lodash.mapValues({ + next, + complete, + error, + }, action => scheduler(action))) +} + +export const asapScheduler = action => (...args) => { + Promise.resolve().then(() => action(...args)) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/pairwise.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/pairwise.ts new file mode 100644 index 000000000..2bb8fbd05 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/pairwise.ts @@ -0,0 +1,22 @@ +import { PublishContext } from '../subject' + +export const pairwise = () => ({ + subscribe, + next, +}: Pick, 'subscribe' | 'next'>) => { + const buffer = [] + + subscribe(value => { + if (buffer.length === 2) { + buffer.shift() + } + buffer.push(value) + if (buffer.length === 2) { + next(buffer.slice()) + } + }) + + return () => { + buffer.length = 0 + } +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/startWith.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/startWith.ts new file mode 100644 index 000000000..923bf7357 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/startWith.ts @@ -0,0 +1,12 @@ +import { PublishContext } from '../subject' + +export const startWith = (...values: any[]) => ({ next, subscribe }: PublishContext) => { + let seen = false + subscribe(value => { + if (!seen) { + values.forEach(v => next(v)) + } + next(value) + seen = true + }) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/tap.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/tap.ts new file mode 100644 index 000000000..47649a4a5 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/tap.ts @@ -0,0 +1,20 @@ +import { Observer, PublishContext, toStandardizedObserver } from '../subject' + +export const tap = (observer: Observer) => ({ + subscribe, + next, + error, + complete, +}: PublishContext) => { + const standardizedObserver = toStandardizedObserver(observer) + + subscribe( + lodash.mapValues( + { next, error, complete }, + (v, k) => (value?: T | Error) => { + standardizedObserver[k]?.(value) + v.call(null, value) + }, + ), + ) +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/operators/util/index.ts b/registry/lib/components/video/player/common/mini-rxjs/operators/util/index.ts new file mode 100644 index 000000000..bb2874d7c --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/operators/util/index.ts @@ -0,0 +1,21 @@ +import { TeardownLogic } from '../../subject' + +export const withTeardownLogic = ( + cb: ( + addTeardownLogic: (teardownLogicList: (TeardownLogic | TeardownLogic[] | any)) => void + ) => void, +) => { + const teardownLogicSet = new Set() + + cb(teardownLogicList => { + lodash.castArray(teardownLogicList).forEach(teardownLogic => { + teardownLogicSet.add(teardownLogic) + }) + }) + + return () => { + teardownLogicSet.forEach(teardownLogic => { + teardownLogic() + }) + } +} diff --git a/registry/lib/components/video/player/common/mini-rxjs/subject.ts b/registry/lib/components/video/player/common/mini-rxjs/subject.ts new file mode 100644 index 000000000..87e43b19e --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/subject.ts @@ -0,0 +1,142 @@ +import { getGeneralSettings } from '@/core/settings' + +export type EmptyFunction = () => void +export type TeardownLogic = EmptyFunction +export type Unsubscribe = EmptyFunction + +export interface StandardizedObserver { + next(value?: T): void + error?(err: Error): void + complete?(): void +} + +export type Observer = ((value: T) => void) | StandardizedObserver + +export interface PublishContext + extends Required, 'completed'>> { + subscribe?(observer?: Observer): Unsubscribe | undefined +} + +export interface Publisher { + (context: PublishContext): TeardownLogic | void +} + +export interface Operator { + (context: PublishContext): TeardownLogic | void +} + +export interface Subject extends Required> { + connect(): void + pipe(...operators: Operator[]): Subject +} + +export const toStandardizedObserver = ( + observer: Observer, +): StandardizedObserver => (typeof observer === 'function' ? { next: observer } : observer) + +export const subject = ( + publisher?: Publisher, +): Subject => (function internalSubject(publisher_, parent = undefined, root = undefined) { + let connected = false + + const teardownLogicList: TeardownLogic[] = [] + const observers = [] + let completed = false + + const cleanup = () => { + while (teardownLogicList.length) { teardownLogicList.pop()() } + observers.length = 0 + completed = true + } + + const error = (err: Error) => { + if (completed) { + return + } + observers.forEach(observer => { + observer.error?.(err) + getGeneralSettings().devMode && console.error(err) + }) + cleanup() + } + + const next = (value: R) => { + if (completed) { + return + } + observers.forEach(observer => { + try { + observer.next(value) + } catch (err) { + error(err) + } + }) + } + + const complete = () => { + observers.forEach(observer => { + observer.complete?.() + }) + cleanup() + } + + const connect = () => { + if (connected) { + return + } + const teardownLogic = publisher_?.({ next, error, complete }) + teardownLogic && teardownLogicList.push(teardownLogic) + connected = true + } + + const subscribe = (observer?: StandardizedObserver) => { + if (observer == null) { + return null + } + observers.push(observer) + return () => { + lodash.pull(observers, observer) + } + } + + const pipe = (...publishers: Publisher[]) => { + if (publishers.length === 0) { + return { + subscribe: observer => { + const unsubscribe = subscribe(toStandardizedObserver(observer)); + (root?.connect ?? connect)() + return unsubscribe + }, + pipe, + next, + error, + complete, + ...root, + } + } + return internalSubject( + publishers[0], + { subscribe }, + root || { + connect, + next, + }, + ).pipe(...publishers.slice(1)) + } + + if (parent) { + const teardownLogic = publisher_?.({ + subscribe: observer => parent.subscribe( + // 默认传递本级的 error 和 complete,这样实现操作符时,将简单许多,一般情况下,错误和完成信号都能沿着链式调用传递下去 + { error, complete, ...toStandardizedObserver(observer) }, + ), + next, + error, + complete, + }) + teardownLogic + && teardownLogicList.push(teardownLogic) + } + + return pipe() + }(publisher)) diff --git a/registry/lib/components/video/player/common/mini-rxjs/utils.ts b/registry/lib/components/video/player/common/mini-rxjs/utils.ts new file mode 100644 index 000000000..1c7a9f3cb --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/utils.ts @@ -0,0 +1,14 @@ +import { Subject as SubjectReturnType } from './subject' + +export const firstValueFrom = (subject: SubjectReturnType) => new Promise( + (resolve, reject) => { + const unsubscribe = subject.subscribe({ + next: (value:T) => { + resolve(value) + unsubscribe() + }, + error: () => { reject(); unsubscribe() }, + complete: () => { reject(); unsubscribe() }, + }) + }, +) diff --git a/registry/lib/components/video/player/common/mini-rxjs/utils/firstValueFrom.ts b/registry/lib/components/video/player/common/mini-rxjs/utils/firstValueFrom.ts new file mode 100644 index 000000000..bec1dc18f --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/utils/firstValueFrom.ts @@ -0,0 +1,14 @@ +import { Subject } from '../subject' + +export const firstValueFrom = (subject: Subject) => new Promise( + (resolve, reject) => { + const unsubscribe = subject.subscribe({ + next: (value:T) => { + resolve(value) + unsubscribe() + }, + error: () => { reject(); unsubscribe() }, + complete: () => { reject(); unsubscribe() }, + }) + }, +) diff --git a/registry/lib/components/video/player/common/mini-rxjs/utils/loadStyle.ts b/registry/lib/components/video/player/common/mini-rxjs/utils/loadStyle.ts new file mode 100644 index 000000000..8cb9c4315 --- /dev/null +++ b/registry/lib/components/video/player/common/mini-rxjs/utils/loadStyle.ts @@ -0,0 +1,26 @@ +import { addStyle } from '@/core/style' + +export interface LoadStyleOptions { + style: string | ((dep: D) => any) + name?: string + container?: HTMLElement +} + +export const loadStyle = ( + { style, name, container }: LoadStyleOptions, +) => { + let styleElement + const complete = () => styleElement?.remove() + const next = (dep: D) => { + complete() + const styleText = typeof style === 'function' ? style(dep) : style + if (!styleText) { + return + } + styleElement = addStyle(styleText, name, container) + } + return { + next, + complete, + } +} diff --git a/registry/lib/components/video/player/common/speed.ts b/registry/lib/components/video/player/common/speed.ts new file mode 100644 index 000000000..3b6b152a5 --- /dev/null +++ b/registry/lib/components/video/player/common/speed.ts @@ -0,0 +1,216 @@ +import { + ComponentEntry, + ComponentMetadata, + ComponentOption, +} from '@/components/types' +import { CoreApis } from '@/core/core-apis' +import { addComponentListener, ComponentSettings } from '@/core/settings' +import { logError } from '@/core/utils/log' +import { getHook } from '@/plugins/hook' +import { + bindCallback, + Subject, + subject, + TeardownLogic, +} from './mini-rxjs' +import { distinctUntilChanged } from './mini-rxjs/operators/distinctUntilChanged' +import { + getSpeedContext, SpeedContext, SpeedSeekPosition, useShareBuildArgument$, +} from './speed/context' + +export type VideoIdObject = { aid: string; cid: string } + +/** 原生支持的倍速值 */ +export const NATIVE_SUPPORTED_VALUES = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] +/** 浏览器支持设定的最小倍速值 */ +export const MIN_BROWSER_SPEED_VALUE = 0.0625 +/** 浏览器支持设定的最大倍速值 */ +export const MAX_BROWSER_SPEED_VALUE = 16 + +export type EntryContext = Parameters[0] + +export type OptionSubjects = O & { [K in keyof O as `${Exclude}$`]: Subject } + +export class EntrySpeedComponent> +implements EntryContext { + static create: < + OO extends Record = unknown + >(metadata: Omit< + ComponentMetadata, + 'entry' | 'reload' | 'unload' | 'options' + > & { + options?: { [K in keyof OO]: ComponentOption } + }) => ComponentMetadata; + + static contextMap: Partial> = { + getVideoIdObject: 'videoIdObject', + getAvailableSpeedValues: 'getAvailableSpeedValues', + getOldActiveVideoSpeed: 'getOldActiveVideoSpeed', + getVideoSpeed: 'videoElement.playbackRate', + setVideoSpeed: 'set', + forceVideoSpeed: 'force', + resetVideoSpeed: 'reset', + toggleVideoSpeed: 'toggle', + increaseVideoSpeed: 'increase', + decreaseVideoSpeed: 'decrease', + } + + constructor(protected readonly entryContext: EntryContext) { + lodash.assign( + this, + entryContext, + { + options: entryContext.settings.options, + }, + ) + + // 执行迁移操作 + this.migrate?.() + + lodash.assign( + this, + lodash.mapValues( + EntrySpeedComponent.contextMap, + path => async (...args) => { + // 这里调用 getSpeedContext 不是第一次调用,因此拿到的一定是缓存 + // 无需传递 build 参数 + const context = await getSpeedContext() + const value = lodash.get(context, path) as any + const result = lodash.isFunction(value) ? await value(...args) : value + return result + }, + ), + ) + } + + speedContext?: SpeedContext + settings: ComponentSettings + coreApis: CoreApis + metadata: ComponentMetadata + options: OptionSubjects + readonly getVideoIdObject: () => Promise + readonly getAvailableSpeedValues: () => Promise + readonly getOldActiveVideoSpeed: () => Promise + readonly forceVideoSpeed: (value: number) => Promise + readonly getVideoSpeed: () => Promise + readonly setVideoSpeed: (value: number, timeout?: number) => Promise + readonly resetVideoSpeed: () => Promise + readonly toggleVideoSpeed: + (legacy?: boolean) => Promise + | ((offset: number, whence?: SpeedSeekPosition) => Promise) + readonly increaseVideoSpeed: () => Promise + readonly decreaseVideoSpeed: () => Promise + + getSpeedContextMixin?(context: SpeedContext): Partial + onSpeedContext?(context: SpeedContext): TeardownLogic | any + protected migrate?(): void +} + +getSpeedContext((components: EntrySpeedComponent[]) => disposableSpeedContext => { + const safeContext = lodash.omit(disposableSpeedContext, 'dispose') + + const mixins = components.map(component => component.getSpeedContextMixin(safeContext)) + + if (mixins.length > 1) { + // 检查是否有重复覆盖的字段 + const repeatedKeys = lodash.intersection(...mixins.map(Object.keys)) + + if (repeatedKeys.length) { + throw new Error( + 'In the registered speed ​​component, there is an implementation of getSpeedContextMixin that causes the speed context to be mixed in ambiguous.\n' + + `The repeated key names are ${repeatedKeys.join(', ')}`, + ) + } + } + + // 混入 + lodash.assign(safeContext, ...mixins) + + const allOptions$: Subject[] = [] + + components.forEach(component => { + const options$ = lodash(component.settings.options) + .mapValues( + (_, optionName) => bindCallback( + addComponentListener, + `${component.metadata.name}.${optionName}`, + ).pipe(distinctUntilChanged()), + ) + .mapKeys((_, optionName) => `${optionName}$`) + .value() + + allOptions$.push(...lodash.values(options$)) + + // 重新生成 options 代理对象 + component.options = new Proxy>( + component.settings.options, { + get: (target, p, receiver) => { + if (lodash.isSymbol(p)) { + return Reflect.get(target, p, receiver) + } + if (!Reflect.has(target, p) && p.endsWith('$')) { + return options$[p] + } + return Reflect.get(target, p, receiver) + }, + }, + ) + + component.speedContext = safeContext + component.onSpeedContext(safeContext) + + if (!component.settings.enabled) { + return + } + + // 通知一下组件的选项值 + lodash(options$).entries().forEach(([optionName, optionValue$]: [string, Subject]) => { + optionValue$.next( + component.settings.options[optionName.slice(0, -1)], + ) + }) + }) + + return { + ...safeContext, + dispose: () => { + allOptions$.forEach(option$ => option$.complete()) + disposableSpeedContext.dispose() + }, + } +}) + +// 不知道为啥,Webpack 会用 EntrySpeedComponent 直接替换类内定义的静态函数中的 this +// 这将导致 create 函数无法正常工作,因此只能在类外定义 +// 这样做也不是没有缺点,typescript 会认为此函数不是 EntrySpeedComponent 的一部分,对于 EntrySpeedComponent 静态成员的访问受限 +EntrySpeedComponent.create = function create(metadata) { + const enabled$ = subject().pipe(distinctUntilChanged()) + + return { + ...metadata, + entry: (entryContext: EntryContext) => { + const component: EntrySpeedComponent | Error = lodash.attempt(() => new this( + entryContext, + )) + + if (component instanceof Error) { + logError(component) + return null + } + + const [shareBuildArgument$] = useShareBuildArgument$() + + enabled$.subscribe(() => { + shareBuildArgument$.next(component) + }) + + shareBuildArgument$.next(component) + + getHook(`speed.component.${metadata.name}`).after(component) + + return component + }, + reload: () => enabled$.next(true), + unload: () => enabled$.next(false), + } +} diff --git a/registry/lib/components/video/player/common/speed/context.ts b/registry/lib/components/video/player/common/speed/context.ts new file mode 100644 index 000000000..1f309999c --- /dev/null +++ b/registry/lib/components/video/player/common/speed/context.ts @@ -0,0 +1,557 @@ +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', + speedMenuItem: '.bilibili-player-video-btn-speed-menu-list', + speedNameBtn: '.bilibili-player-video-btn-speed-name', + speedContainer: '.bilibili-player-video-btn-speed', + active: '.bilibili-player-active', + show: '.bilibili-player-speed-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 + readonly speedContext$: Subject + readonly videoIdObject: VideoIdObject + readonly containerElement: HTMLElement + readonly videoElement: HTMLVideoElement + readonly nameBtnElement: HTMLElement + readonly menuListElement: HTMLElement + readonly activeVideoSpeed$: Subject + readonly playbackRate$: Subject + readonly playbackRateChange$: Subject + readonly menuListElementClickSpeed$: Subject + readonly menuListElementClickSpeedChange$: Subject + readonly videoSpeedChange$: Subject + 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 + /** + * 强行设置倍速 + * + * 通过 {@link set} 或 {@link toggle} 方法设置的倍速,可能会因为指定的倍速值不在倍速菜单里而失败,如果想要强行设置倍速值,就需要使用此方法 + * + * 【注意】谨慎使用此方法,这个方法不会更新倍速菜单的状态,有可能造成状态不一致的现象,而且还可能会影响上次倍速值,从而出现一些其他的副作用 + */ + force(value: number): Promise + /** 重置倍速为 1.0x */ + reset(): Promise + /** + * 切换倍速 + * + * 默认情况下,如果当前倍速不是 1.0x,则切换到 1.0x(即重置当前视频倍速),否则切换到上次倍速 + * + * @param legacy 传统方式:无论当前倍速如何,均切换到上次倍速 + */ + toggle(legacy?: boolean): Promise + /** + * 切换倍速 + * + * 类似文件系统 API 提供的 `seek` 函数,将倍速菜单被视作可寻址的地址空间,想象一个「指针」指向当前激活的倍速菜单项, + * 「指针」每次移动后所指向的倍速菜单项就成为新的被激活的倍速菜单项,通过给定参照和偏移量移动「指针」,就完成了切换倍速的操作. + * + * @param offset 「指针」偏移量 + * @param whence 可选,默认为当前倍速菜单项,给 offset 一个参照,表示从何处开始偏移量的计算 + */ + toggle(offset: number, whence?: SpeedSeekPosition): Promise + /** + * 提高倍速 + * + * 类似于: + * + * ``` + * toggle(1, SpeedSeekPosition.CURRENT) + * ``` + * + * 与之不同的是,如果当前倍速已经是倍速菜单里最高的倍速,不会产生作用,也不会抛出异常 + */ + increase(): Promise + /** + * 降低倍速 + * + * 类似于: + * + * ``` + * toggle(-1, SpeedSeekPosition.CURRENT) + * ``` + * + * 与之不同的是,如果当前倍速已经是倍速菜单里最低的倍速,不会产生作用,也不会抛出异常 + */ + decrease(): Promise +} + +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( + // 有的时候,选择 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().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.innerHTML))) + .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, +) => { + const { videoElement, menuListElement } = elementContext + + const menuListElementClickSpeed$ = fromEvent( + menuListElement, + 'click', + ).pipe( + 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) => { + 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('speed.speedContext') + +export const useShareBuildArgument$ = () => useShare>( + '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(videoChange).pipe(filter(({ aid, cid }) => aid || cid))) + const [speedContext$] = useShare('speed.speedContext$', () => subject( + ({ 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, + PLAYER_AGENT.query.video.element() as Promise, + ]) + .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$) +} diff --git a/registry/lib/components/video/player/common/speed/utils.ts b/registry/lib/components/video/player/common/speed/utils.ts new file mode 100644 index 000000000..8c11a6edd --- /dev/null +++ b/registry/lib/components/video/player/common/speed/utils.ts @@ -0,0 +1,46 @@ +import { getData, registerAndGetData } from '@/plugins/data' + +export const trimLeadingDot = (selector: string) => selector.replace(/^\./, '') + +export const useShare = ( + key: string, + defaultFactory?: () => T, +): [T | undefined, (value: T) => any] => { + const setShareValue = shared => { + registerAndGetData(key, shared)[0] = shared + } + + const data = getData(key) + + if (data.length) { + return [data[0], setShareValue] + } + if (defaultFactory) { + const defaultValue = defaultFactory() + setShareValue(defaultValue) + return [defaultValue, setShareValue] + } + + return [ + undefined, + setShareValue, + ] +} + +export const formatSpeedText = (speed: number, nameBtnStyle = false) => { + if (nameBtnStyle && speed === 1) { + return '倍速' + } + return Math.trunc(speed) === speed ? `${speed}.0x` : `${speed}x` +} + +export const parseSpeedText = (text: string) => { + if (text === '倍速') { + return 1 + } + const matchResult = /([0-9]*[.]?[0-9]+)x/.exec(text) + if (matchResult) { + return parseFloat(matchResult[1]) + } + throw new Error(`unknown speed text: ${text}`) +} diff --git a/registry/lib/components/video/player/extend-speed/component.ts b/registry/lib/components/video/player/extend-speed/component.ts new file mode 100644 index 000000000..a350fedec --- /dev/null +++ b/registry/lib/components/video/player/extend-speed/component.ts @@ -0,0 +1,486 @@ +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, 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 { + tag?: T + node: N + destroy(): void +} + +const $ = , 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 { + 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 { + const { input, root, icon } = $<{ input: HTMLInputElement, icon: HTMLElement }, HTMLLIElement>(` +
  • + + +
  • + `) + + 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(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 { + const { closeBtn, root } = $<{ closeBtn: HTMLElement }, HTMLLIElement>(` +
  • + ${formatSpeedText(value)} + +
  • + `) + + const styleElement = addStyle(` + .${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"] { + color: inherit; + opacity: 0.5; + display: none; + position: absolute; + right: 4px; + } + ${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[] = []; + protected inputElement: VNode + protected unpatch: TeardownLogic + + protected migrate() { + const { options } = this.settings + const { options: rememberSpeedOptions } = getComponentSettings('rememberVideoSpeed') as ComponentSettings + 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 { + return { + query: (speed: number) => des( + // 有的时候,选择 1.0x 倍速,会错误选中 11.0x 倍速 + // 删掉 11.0x 倍速后,再选择 1.0x 倍速就没出现该问题,这是因为之前的实现使用 contains 函数判断,由于 11.0x 倍速文本包含 1.0x,所以被误判了 + // 原先使用 contains 而不直接比较的原因是,自定义的倍速菜单项,可能在创建时引入了多余的空白符,现在使用 normalize-space 代替之前的做法 + `./*[contains(@class, "${ExtendSpeedComponent.speedMenuItemClassName}")` + + ` 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; + flex-direction: column; + overflow-y: auto; + max-height: ${maxMenuHeight}px; + }`, + }), + ) + + 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; + } + ${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) => { + 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( + `${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( + `./*[contains(@class, "${ExtendSpeedComponent.speedMenuItemClassName}") and contains(@class, "${ExtendSpeedComponent.activeClassName}")]`, + menuListElement, + ) as Iterable) { + element.classList.remove(ExtendSpeedComponent.activeClassName) + } + // 对于被强制更新的菜单项,添加激活态的类名 + querySpeedMenuItemElement(value).classList.add(ExtendSpeedComponent.activeClassName) + // 关闭菜单 + containerElement.classList.remove(ExtendSpeedComponent.showClassName) + // 更新倍速菜单按钮文本 + nameBtnElement.innerText = formatSpeedText( + value, + true, + ) + } +} diff --git a/registry/lib/components/video/player/extend-speed/index.ts b/registry/lib/components/video/player/extend-speed/index.ts new file mode 100644 index 000000000..2ea5544f1 --- /dev/null +++ b/registry/lib/components/video/player/extend-speed/index.ts @@ -0,0 +1,56 @@ +import { playerUrls } from '@/core/utils/urls' +import { MAX_BROWSER_SPEED_VALUE, MIN_BROWSER_SPEED_VALUE } from '../common/speed' +import { ExtendSpeedComponent, Options } from './component' + +export const component = ExtendSpeedComponent.create({ + name: 'extendVideoSpeed', + displayName: '扩展倍速', + author: { + name: 'JLoeve', + link: 'https://github.com/LonelySteve', + }, + description: { + 'zh-CN': ` + +> 扩展视频播放器的倍速菜单项,可用于突破原有播放倍数的上限或下限. + +### 🔧 **选项** + +- \`隐藏滚动条\`:如果添加的倍速过多,倍速菜单将出现滚动条,在 Windows 下,若没有安装并启用「细滚动条」组件会显得比较挤,建议开启此选项隐藏滚动条. + +### **新增倍速** + +开启组件后,在默认情况下,播放器的倍速菜单就会新增 2.5x 和 3.0x 两个倍速选项. + +如果需要添加更多倍速,只需将鼠标指针移到菜单顶部的新增图标上,图标将变成一个输入框,根据需要键入新的倍速值,或通过滚轮增减数值,或直接使用推荐的数值,回车确认即可. + +新增倍速的范围要求在 ${MIN_BROWSER_SPEED_VALUE} 到 ${MAX_BROWSER_SPEED_VALUE} 之间,数量则不受限制. + +**不推荐设置超高倍速(>3.0x)**:原生播放器内部没有针对超高倍速进行优化,可能导致音画不同步、播放卡顿、声音嘈杂/消失等一系列问题. + +### **删除倍速** + +将鼠标指针移到**自定义**的倍速菜单项上,其右侧将会显示一个移除图标,单击即可删除相应的倍速. + +`, + }, + tags: [componentsTags.video], + urlInclude: playerUrls, + options: { + maxMenuHeight: { + displayName: '倍速菜单最大高度', + defaultValue: 360, + hidden: true, + validator: val => Math.max(parseInt(val), 360) || 360, + }, + hideScrollbar: { + displayName: '隐藏滚动条', + defaultValue: false, + }, + extendSpeedList: { + displayName: '扩展倍速列表', + defaultValue: [2.5, 3], + hidden: true, + }, + }, +}) diff --git a/registry/lib/components/video/player/remember-speed/component.ts b/registry/lib/components/video/player/remember-speed/component.ts new file mode 100644 index 000000000..2dcebd61a --- /dev/null +++ b/registry/lib/components/video/player/remember-speed/component.ts @@ -0,0 +1,221 @@ +import { Toast } from '@/core/toast' +import { EntrySpeedComponent, VideoIdObject } from '../common/speed' +import { NoSuchSpeedMenuItemElementError, SpeedContext } from '../common/speed/context' +import { formatSpeedText } from '../common/speed/utils' + +export interface Options { + /** 全局倍速 */ + globalSpeed: number + /** 固定全局倍速 */ + fixGlobalSpeed: boolean + /** 独立记忆倍速 */ + individualRemember: boolean + /** 独立记忆倍速记录 */ + individualRememberRecord: Record + /** 弹出还原倍速提示 */ + showRestoreTip: boolean +} + +export class RememberSpeedComponent extends EntrySpeedComponent { + static readonly getAid = (aid = unsafeWindow.aid) => { + if (!aid) { + throw new Error('aid is unknown') + } + return aid + } + + protected static readonly aidComparator = + (a: string | number, b: string | number) => a.toString() === b.toString() + + getSpeedContextMixin({ + videoIdObject, set, reset: originReset, toggle, getActiveVideoSpeed, getOldActiveVideoSpeed, + }: SpeedContext): Partial { + const reset = async () => { + const restoredVideoSpeed = this.getRestoredVideoSpeed(videoIdObject) + await set(restoredVideoSpeed ?? 1) + } + return { + reset, + // 当固定全局倍速且不开启独立记忆的情况下,普通的切换操作是在全局倍速与上一次倍速之间切换 + // 否则,就是在 1.0x 倍速与上一次倍速之间切换 + toggle: async (...args) => { + const [legacyOrOffset, ...restArgs] = args + // seek 用法,使用原 toggle 实现 + if (legacyOrOffset != null && typeof legacyOrOffset !== 'boolean') { + toggle(legacyOrOffset, ...restArgs) + return + } + const { fixGlobalSpeed, individualRemember, globalSpeed } = this.options + const individualFixGlobalSpeed = fixGlobalSpeed && !individualRemember + + if ( + legacyOrOffset + || getActiveVideoSpeed() === (individualFixGlobalSpeed ? globalSpeed : 1) + ) { + await set(getOldActiveVideoSpeed()) + return + } + + await (individualFixGlobalSpeed ? reset() : originReset()) + }, + } + } + + protected migrate() { + const { options } = this.settings + let flag = false + if (options.speed) { + options.globalSpeed = +options.speed || 1 + delete options.speed + flag = true + } + if (options.individualRememberList) { + options.individualRememberRecord = lodash.cloneDeep( + options.individualRememberList, + ) as Record + delete options.individualRememberList + flag = true + } + if (flag) { + options.fixGlobalSpeed = false + options.showRestoreTip = true + delete options.remember + Toast.show('「扩展倍速」和倍速快捷键插件成为独立的组件或插件啦!详情请阅读组件描述.(此弹出提醒仅显示一次)', '【记忆倍速】迁移提醒', 8e3) + } + } + + onSpeedContext({ videoSpeedChange$, videoIdObject }: SpeedContext) { // 如果开启「独立记忆倍速」,则同时开启「固定全局倍速」 + this.options.individualRemember$.subscribe(value => { + if (value) { + this.options.fixGlobalSpeed = true + } + }) + // 如果关闭「固定全局倍速」,则同时关闭「独立记忆倍速」 + this.options.fixGlobalSpeed$.subscribe(value => { + if (!value) { + this.options.individualRemember = false + } + }) + const restoredVideoSpeed = this.getRestoredVideoSpeed(videoIdObject) + // 在用户第一次使用倍速记忆功能时,restoredVideoSpeed 可能为空 + if (restoredVideoSpeed) { + // 还原记忆的倍速值 + requestIdleCallback(async () => { + try { + await this.setVideoSpeed(restoredVideoSpeed, 1000) + if (this.options.showRestoreTip) { + let msg = `已还原到 ${formatSpeedText(restoredVideoSpeed)} 倍速` + if (this.options.individualRemember && this.matchRememberSpeed() != null) { + msg = `【独立倍速视频】${msg}` + } + Toast.info(msg, this.metadata.displayName, 3000) + } + } catch (err) { + const toastTitle = `${this.metadata.displayName} - 倍速还原操作失败` + const toastContent = err instanceof NoSuchSpeedMenuItemElementError ? `没有 ${err.formattedSpeed} 这样的倍速项` : String(err) + Toast.error(toastContent, toastTitle, 5000) + console.error(err) + } + }) + } + + videoSpeedChange$.subscribe(value => { + if (!this.settings.enabled) { + return + } + // 记忆倍速值 + if (this.options.individualRemember) { + // 仅当被设定的倍速不等于全局记忆倍速时,才作为新的视频级别倍速记忆 + if (value !== +this.options.globalSpeed) { + this.rememberSpeed(value) + } + } else if (!this.options.fixGlobalSpeed) { + this.rememberSpeed(value, null) + } + }) + } + + protected getRestoredVideoSpeed(videoIdObject: VideoIdObject) { + // 按以下优先级尝试获取需要还原的倍速值 + // - 如果启用了“分视频记忆(individualRemember)”,则使用当前视频相应的记忆倍速值(如果有) + // - 使用“全局记忆值(speed)” + return ( + this.options.individualRemember + && this.matchRememberSpeed(videoIdObject.aid) + ) + || this.readGlobalVideoSpeed() + } + + /** + * 读取全局记忆的倍速值 + * + * @returns 全局记忆的倍速值 + */ + readGlobalVideoSpeed() { + return parseFloat(String(this.options.globalSpeed)) + } + + /** + * 根据 aid 匹配记忆的倍速(用于区分视频记忆倍速的情形) + * + * @param aid 欲匹配的 aid,缺省情况下取当前页面视频的 aid + * @returns 匹配的相应记忆倍速 + */ + matchRememberSpeed(aid?: string) { + for (const [level, aids] of Object.entries(this.options.individualRememberRecord)) { + if (aids.some(aid_ => aid_.toString() === RememberSpeedComponent.getAid(aid).toString())) { + return parseFloat(level) + } + } + return null + } + + /** + * 记忆指定倍速 + * + * @param speed 要记忆的倍速 + * @param aid 要针对性记忆的 aid 或 aid 数组,若不指定则从页面中自动获取,若指定为 `null`,则将 `speed` 参数以全局倍速值记忆 + */ + rememberSpeed(speed: number, aid?: string | string[] | null) { + // 全局记忆 + if (lodash.isNull(aid)) { + this.options.globalSpeed = speed + return + } + // 针对特定视频/当前视频记忆 + if (lodash.isUndefined(aid)) { + aid = RememberSpeedComponent.getAid(aid) + } + const aidList = lodash.castArray(aid) + this.forgetSpeed(aidList) + this.options.individualRememberRecord = { + ...this.options.individualRememberRecord, + [speed]: lodash.unionWith( + this.options.individualRememberRecord[speed], + aidList, + RememberSpeedComponent.aidComparator, + ), + } + } + + /** + * 忘记对指定 aid 记忆的倍速,返回值表示指定的 aid 之前是否被记忆 + * + * @param aid 要忘记的 aid 或 aid 数组,若不指定则从页面中自动获取 + */ + forgetSpeed(aid?: string | string[]) { + if (lodash.isNil(aid)) { + aid = RememberSpeedComponent.getAid(aid) + } + + const aidList = lodash.castArray(aid) + + this.options.individualRememberRecord = lodash(this.options.individualRememberRecord) + .mapValues(aids => lodash(aids) + .pullAllWith(aidList, RememberSpeedComponent.aidComparator) + .uniqWith(RememberSpeedComponent.aidComparator) // 避免重复的项目 + .value()) + .pickBy((v: (string | number)[]) => v.length) // 避免留下无用的空数组 + .value() + } +} diff --git a/registry/lib/components/video/player/remember-speed/controller.ts b/registry/lib/components/video/player/remember-speed/controller.ts deleted file mode 100644 index a61d36199..000000000 --- a/registry/lib/components/video/player/remember-speed/controller.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { playerAgent } from '@/components/video/player-agent' -import { videoChange } from '@/core/observer' -import { - addListener, - calcOrder, - extendedAgent, - formatSpeedText, - getAid, - getExtendedSupportedRates, - getSupportedRates, - nativeSupportedRates, - options, - removeListeners, - trimLeadingDot, -} from './utils' - -const activeClassName = trimLeadingDot(extendedAgent.custom.active.selector) -const showClassName = trimLeadingDot(extendedAgent.custom.show.selector) - -// 原生倍速,应该与官方播放器内部维护的值保持一致 -let nativeSpeed: number -// 分 P 切换时共享前一个倍速,这里指定初始倍速可以是 undefined,不需要是 1 -let sharedPreviousSpeed: number -// 分 P 切换时共享同一个倍速,这里指定初始倍速可以是 undefined,不需要是 1 -let sharedSpeed: number -// 分 P 切换时共享同一个原生倍速值,初始值设置为 1 -let sharedNativeSpeed = 1 - -let containerElement: HTMLElement -let menuListElement: HTMLElement -let videoElement: HTMLVideoElement -let nameBtn: HTMLButtonElement - -const getRememberSpeed = (aid?: string) => { - for (const [level, aids] of Object.entries(options.individualRememberList)) { - if (aids.some(aid_ => aid_.toString() === getAid(aid).toString())) { - return parseFloat(level) - } - } - return null -} - -const getFallbackVideoSpeed = () => { - // 如果组件被启用才使用存储的后备值 - if (options.remember) { - return parseFloat(options.speed) - } - return null -} - -/** - * 忘记对指定 aid 记忆的倍速,返回值表示指定的 aid 之前是否被记忆 - * - * @param aid 要忘记的 aid,若不指定则从页面中自动获取 - */ -const forgetSpeed = (aid?: string) => { - aid = getAid(aid) - - let aidOldIndex = -1 - for (const aids of Object.values(options.individualRememberList)) { - aidOldIndex = aids.indexOf(aid) - if (aidOldIndex !== -1) { - aids.splice(aidOldIndex, 1) - break - } - } - - return aidOldIndex !== -1 -} - -/** - * 为指定 aid 记忆指定倍速 - * - * @param speed 要记忆的倍速 - * @param force 对于之前没有被记忆的 aid,**如果不将此参数设置为 `true`,调用完成也不会将相应的倍速记忆到设置中的** - * @param aid 要记忆的 aid,若不指定则从页面中自动获取 - */ -const rememberSpeed = (speed: number, force = false, aid?: string) => { - aid = getAid(aid) - const remembered = forgetSpeed(aid) - // 对于没有被记忆的 aid,并且 force 参数为假就直接返回 - if (!remembered && !force) { - return - } - // 为新的速度值初始化相应的 aid 数组 - if (!options.individualRememberList[speed]) { - options.individualRememberList[speed] = [] - } - // 追加记忆值 - options.individualRememberList[speed].push(aid) -} - -const getSpeedMenuItem = (speed: number) => menuListElement.querySelector(`${extendedAgent.custom.speedMenuItem.selector}[data-value="${speed}"]`) as (HTMLElement | null) - -/** - * 设置视频倍速值,将返回当前的倍速 - * - * @param speed 欲设置的倍速值 - */ -const videoSpeed = (speed?: number) => { - if (speed) { - getSpeedMenuItem(speed)?.click() - return speed - } - return videoElement.playbackRate -} - -const setVideoSpeed = videoSpeed - -/** - * 重置视频倍速 - * - * @param withForget 是否为附带清除视频记忆的重置倍速操作 - */ -const resetVideoSpeed = (withForget = false) => { - if (withForget) { - const fallbackVideoSpeed = getFallbackVideoSpeed() - // 如果 fallbackVideoSpeed 是 undefined,那么意味着没有开启记忆倍速功能 - // 考虑到与清除视频级别的倍速记忆功能的相关性,这里会忽略设定 - // 简单地说,如果没有开启记忆倍速的功能,就无法清除视频级别的倍速记忆 - if (!fallbackVideoSpeed) { - return - } - forgetSpeed() - setVideoSpeed(fallbackVideoSpeed) - } else { - setVideoSpeed(1) - } -} - -/** - * 切换当前倍速 - * - * 根据`mode`参数的不同有着不同的行为: - * - * - `mode === "smart"`(默认):当前倍速等于 1.0x 时,切换到上次不同的视频倍速,否则重置倍速为 1.0x - * - `mode === "classic"`:无论当前倍速如何,均切换到上次不同的视频倍速 - * - * 重置倍速的行为可由 `reset()` 方法同款参数 `forget` 来控制 - * - * @param forget 指示是否为清除视频记忆的重置倍速操作 - */ -const toggleVideoSpeed = (mode: 'smart' | 'classic' = 'smart', forget = false) => { - switch (mode) { - case 'smart': - videoSpeed() === 1 ? videoSpeed(sharedPreviousSpeed) : resetVideoSpeed(forget) - break - case 'classic': - setVideoSpeed(sharedPreviousSpeed) - break - default: - break - } -} - -const forceUpdate = (value: number) => { - menuListElement.querySelector(`${extendedAgent.custom.speedMenuItem.selector}[data-value="${videoSpeed()}"]`)?.classList.remove(activeClassName) - videoElement.playbackRate = value - menuListElement.querySelector(`${extendedAgent.custom.speedMenuItem.selector}[data-value="${value}"]`)?.classList.add(activeClassName) - containerElement.classList.remove(showClassName) - nameBtn.innerText = formatSpeedText(value) -} - -const setExtendedVideoSpeed = (speed: number) => { - if (nativeSupportedRates.includes(speed)) { - getSpeedMenuItem(speed)?.click() - } else { - forceUpdate(speed) - } -} - -const extendList = async () => { - const { getExtraSpeedMenuItemElements } = await import('./extend') - menuListElement.prepend(...await getExtraSpeedMenuItemElements()) - // 为所有原生倍速菜单项设置 Order - menuListElement.querySelectorAll(`${extendedAgent.custom.speedMenuItem.selector}[data-value]:not(.extended)`).forEach( - (it: HTMLLIElement) => { it.style.order = calcOrder(parseFloat(it.getAttribute('data-value') ?? '1')) }, - ) - // 如果开启了扩展倍速,存在一种场景使倍速设置会失效: - // 1. 用户从原生支持的倍速切换到扩展倍速 - // 2. 用户从扩展倍速切换到之前选中的原生倍速 - // 这是因为播放器内部实现维护了一个速度值,但是在切换到扩展倍速时没法更新,因此切换回来的时候被判定没有发生变化 - // 为了解决这个问题,需要通过 forceUpdate 方法替官方更新元素,并为视频设置正确的倍速,最后关闭菜单 - addListener(menuListElement, ['click', ev => { - const option = (ev.target as HTMLElement) - const value = parseFloat(option.dataset.value as string) - if ((ev.target as HTMLElement).classList.contains('extended')) { - setExtendedVideoSpeed(value) - } - // 从扩展倍速切换到之前选中的原生倍速:强制更新! - if (getExtendedSupportedRates().includes(videoSpeed()) && nativeSpeed === value) { - forceUpdate(value) - } - }]) -} - -const extendListIfAllow = async () => { - // 1. 根据 options 判断是否允许扩展倍速列表 - // 2. 如果已经打过标志 classname 的话,就不能再塞更多元素了 - if (!options.extend || containerElement.classList.contains('extended')) { - return - } - await extendList() - containerElement.classList.add('extended') -} - -const addSpeedChangeEventListener = (cb: (previousSpeed: number, currentSpeed: number) => void) => { - addListener(menuListElement, ['click', ev => { - cb(sharedSpeed, parseFloat((ev.target as HTMLLIElement).dataset.value ?? '1')) - }]) -} - -const dispatchChangedEvent = (previousSpeed: number, currentSpeed: number) => { - const isNativeSpeed = nativeSupportedRates.includes(currentSpeed) - containerElement.dispatchEvent(new CustomEvent('changed', { detail: { speed: currentSpeed, isNativeSpeed, previousSpeed } })) -} - -export const createController = _.once(() => { - videoChange(async () => { - // 移除所有监听器 - removeListeners() - // 重新获取页面元素 - const tmpContainerElement = await extendedAgent.custom.speedContainer() - const tmpVideoElement = (await playerAgent.query.video.element()) as (HTMLVideoElement | null) - if (!tmpContainerElement) { - throw new Error('speed container element not found!') - } - if (!tmpVideoElement) { - throw new Error('video element not found!') - } - containerElement = tmpContainerElement - videoElement = tmpVideoElement - nameBtn = containerElement.querySelector( - extendedAgent.custom.speedNameBtn.selector, - ) as HTMLButtonElement - menuListElement = containerElement.querySelector( - extendedAgent.custom.speedMenuList.selector, - ) as HTMLElement - // 试图插入扩展的倍速菜单项 - await extendListIfAllow() - // 为每一个倍速菜单项附加 dataset - menuListElement.querySelectorAll(extendedAgent.custom.speedMenuItem.selector).forEach(it => { - if (!it.hasAttribute('data-value')) { - const speed = parseFloat(it.textContent).toString() - it.setAttribute('data-value', speed) - } - }) - // 还原共享值 - nativeSpeed = sharedNativeSpeed - // 添加视频倍数变化监听 - addSpeedChangeEventListener(dispatchChangedEvent) - // 视频倍数变化监听处理 - addListener(containerElement, ['changed', ({ detail: { speed, isNativeSpeed, previousSpeed } }: CustomEvent) => { - // 记录(共享)倍速值 - sharedSpeed = speed - if (isNativeSpeed) { - sharedNativeSpeed = speed - nativeSpeed = speed - } - // 原生支持倍速的应用后,需要清除扩展倍速选项上的样式 - if (options.extend && nativeSupportedRates.includes(speed)) { - menuListElement.querySelector(`${extendedAgent.custom.speedMenuItem.selector}.extended${extendedAgent.custom.active.selector}`)?.classList.remove(activeClassName) - } - // 记忆 - // - `options.remember` 表示是否启用记忆 - // - `options.individualRemember` 表示是否启用细化到视频级别的记忆 - if (options.remember) { - if (options.individualRemember) { - rememberSpeed( - speed, - speed !== getFallbackVideoSpeed(), - ) - } else { - options.speed = speed.toString() - } - } - // 刷新 sharedPreviousSpeed - // - 用户可以通过倍速菜单或者倍速快捷键造成类似 1.5x 2.0x 2.0x... 这样的倍速设定序列 - // 我们不希望在第二个 2.0x 的时候刷新 this._previousSpeedVal,这样会比较死板 - // 判定依据在于 previousSpeed !== speed - if (previousSpeed && previousSpeed !== speed) { - sharedPreviousSpeed = previousSpeed - } - }]) - // 恢复记忆的倍速值 - // - 首次加载可能会遇到意外情况,导致内部强制更新失效,因此延时 100 ms 再触发速度设置 - setTimeout(() => { - setVideoSpeed( - ( - options.remember - && options.individualRemember - && getRememberSpeed() - ) - || getFallbackVideoSpeed() - || sharedSpeed, - ) - }, 100) - }) - return { - getSupportedRates, - getExtendedSupportedRates, - setVideoSpeed, - videoSpeed, - getRememberSpeed, - rememberSpeed, - forgetSpeed, - resetVideoSpeed, - toggleVideoSpeed, - } -}) diff --git a/registry/lib/components/video/player/remember-speed/extend.ts b/registry/lib/components/video/player/remember-speed/extend.ts deleted file mode 100644 index e5a9bf8f7..000000000 --- a/registry/lib/components/video/player/remember-speed/extend.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { addStyle } from '@/core/style' -import { logError } from '@/core/utils/log' -import { - calcOrder, - errorMessageDuration, - extendedAgent, - getExtendedSupportedRates, - formatSpeedText, - getUniqueAscendingSortList, - maxValue, - minValue, - nativeSupportedRates, - options, - stepValue, - getSupportedRates, - trimLeadingDot, -} from './utils' - -const getRecommendedValue = () => { - const val = getSupportedRates().slice(-1)[0] + stepValue - return val > maxValue ? null : val -} - -const speedMenuItemClassName = trimLeadingDot(extendedAgent.custom.speedMenuItem.selector) - -const createExtendedSpeedMenuItemElement = (rate: number) => { - const li = document.createElement('li') - li.innerText = formatSpeedText(rate) - li.classList.add(speedMenuItemClassName, 'extended') - li.dataset.value = rate.toString() - li.style.order = calcOrder(rate) - const i = document.createElement('i') - i.classList.add('mdi', 'mdi-close-circle') - i.addEventListener('click', () => { - lodash.pull(options.extendList, rate) - li.remove() - }) - li.append(i) - return li -} - -const updateInput = (elem: HTMLInputElement) => { - const recommendedValue = getRecommendedValue() - elem.setAttribute('min', recommendedValue ? (elem.value = recommendedValue.toString()) : (elem.value = '', minValue.toString())) -} - -const createAddEntryElement = () => { - const li = document.createElement('li') - li.classList.add(speedMenuItemClassName) - const iconElement = document.createElement('i') - iconElement.classList.add('mdi', 'mdi-playlist-plus') - const input = document.createElement('input') - input.classList.add('add-speed-entry') - input.setAttribute('type', 'number') - input.setAttribute('max', maxValue.toString()) - input.setAttribute('step', stepValue.toString()) - input.setAttribute('title', '增加新的倍速值') - updateInput(input) - input.addEventListener('keydown', ev => { - if (ev.key === 'Enter') { - const value = parseFloat(input.value) - if (!isFinite(value)) { - logError('无效的倍速值', errorMessageDuration) - return false - } - if (value < minValue) { - logError('倍速值太小了', errorMessageDuration) - return false - } - if (value > maxValue) { - logError('倍速值太大了', errorMessageDuration) - return false - } - if (getSupportedRates().includes(value)) { - logError('不能重复添加已有的倍速值', errorMessageDuration) - return false - } - options.extendList.push(value) - options.extendList = getUniqueAscendingSortList(options.extendList) - - let afterElement = li.nextElementSibling as HTMLLIElement - while ( - !afterElement.dataset.value - || (parseFloat(afterElement.dataset.value) - > nativeSupportedRates.slice(-1)[0] - && value < parseFloat(afterElement.dataset.value)) - ) { - afterElement = afterElement.nextElementSibling as HTMLLIElement - } - afterElement.before(createExtendedSpeedMenuItemElement(value)) - updateInput(input) - } - return true - }) - - li.prepend(iconElement, input) - - input.style.display = 'none' - li.addEventListener('mouseenter', () => { - updateInput(input) - input.style.display = 'inline' - iconElement.style.display = 'none' - input.focus() - }) - li.addEventListener('mouseleave', () => { - iconElement.style.display = 'inline' - input.style.display = 'none' - }) - - return li -} - -export const getExtraSpeedMenuItemElements = async () => { - // 应用样式 - addStyle(` - ${extendedAgent.custom.speedContainer.selector} ${extendedAgent.custom.speedMenuItem.selector}:first-child .mdi-playlist-plus { - font-size: 1.5em; - } - ${extendedAgent.custom.speedContainer.selector} ${extendedAgent.custom.speedMenuItem.selector}:first-child input { - font-size: inherit; - color: inherit; - line-height: inherit; - background: transparent; - outline: none; - width: 100%; - border: none; - text-align: center; - } - ${extendedAgent.custom.speedMenuItem.selector} .mdi-close-circle { - color: inherit; - opacity: 0.5; - display: none; - position: absolute; - right: 4px; - } - ${extendedAgent.custom.speedMenuItem.selector}:not(${extendedAgent.custom.active.selector}):hover .mdi-close-circle { - display: inline; - } - ${extendedAgent.custom.speedMenuItem.selector} .mdi-close-circle:hover { - opacity: 1; - transition: all .3s; - } - /* https://stackoverflow.com/a/4298216 */ - /* Chrome */ - .add-speed-entry::-webkit-outer-spin-button, - .add-speed-entry::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - /* Firefox */ - .add-speed-entry[type=number] { - -moz-appearance:textfield; - } - ${extendedAgent.custom.speedMenuList.selector} { - display: flex; - flex-direction: column; - overflow-y: auto; - max-height: 360px; - } - `, 'extend-video-speed-style') - - const elements = getExtendedSupportedRates() - .map(rate => createExtendedSpeedMenuItemElement(rate)) - .reverse() - - elements.unshift(createAddEntryElement()) - - return elements -} diff --git a/registry/lib/components/video/player/remember-speed/index.ts b/registry/lib/components/video/player/remember-speed/index.ts index 4dfe0b672..56d33fcff 100644 --- a/registry/lib/components/video/player/remember-speed/index.ts +++ b/registry/lib/components/video/player/remember-speed/index.ts @@ -1,125 +1,66 @@ -import { importComponent } from '@/components/component' -import { ComponentMetadata } from '@/components/types' import { playerUrls } from '@/core/utils/urls' -import { KeyBindingAction, KeyBindingActionContext } from 'registry/lib/components/utils/keymap/bindings' -import type { createController } from './controller' +import { MAX_BROWSER_SPEED_VALUE, MIN_BROWSER_SPEED_VALUE } from '../common/speed' +import { Options, RememberSpeedComponent } from './component' -const componentName = 'rememberVideoSpeed' - -type Controller = ReturnType - -export const component: ComponentMetadata = { - name: componentName, - displayName: '倍速增强', +export const component = RememberSpeedComponent.create({ + name: 'rememberVideoSpeed', + displayName: '记忆倍速', author: { name: 'JLoeve', link: 'https://github.com/LonelySteve', }, description: { - 'zh-CN': '可以记忆上次选择的视频播放速度, 还可以使用更多倍速来扩展原生倍速菜单.', + 'zh-CN': ` + +> 提高视频播放器的倍速记忆体验,可实现跨页共享倍速,也可以按视频分别记忆倍速. + +### 🔧 **选项** + +- \`全局记忆倍速值\`:默认情况下,这是跨页共享的倍速值,如果启用「各视频分别记忆」,则作为从未独立记忆倍速视频的初始倍速值. +- \`固定全局倍速值\`:默认情况下,全局倍速值将随着用户改变视频倍速而改变,打开此选项后,全局记忆倍速值不再受倍速调整的影响. +- \`各视频分别记忆\`:打开此选项后,将按不同视频分别记忆倍速,对于从未被记忆过倍速的视频,将采用全局记忆倍速值,选项「固定全局倍速值」在此情况下强制生效. +- \`弹出还原倍速提示\`:打开此选项后,每次成功还原倍速后都会弹出提示. + +### 🌈 **温馨提示** + +「扩展倍速」和倍速相关的快捷键插件已分离为单独的组件或插件. + +请根据自身需要: + +- 前往「组件」页面安装[「扩展倍速」](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/player/extend-speed.js)组件 +- 前往「插件」页面安装[「视频倍速 - 快捷键支持」](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/player/speed.js)插件. + +*如果想要清除当前视频的记忆状态,需要安装「视频倍速 - 快捷键支持」插件.* +`, }, tags: [componentsTags.video], urlInclude: playerUrls, - entry: async () => (await import('./controller')).createController(), - plugin: { - displayName: '倍速增强 - 快捷键支持', - setup: async ({ addData }) => { - const { getComponentSettings } = await import('@/core/settings') - - const videoSpeed = async ( - context: KeyBindingActionContext, - controllerAction: ( - controller: Controller, rates: number[] - ) => void, - ) => { - // 不要提前导入,插件在组件加载之前进行加载,因此如果提前加载会取不到 entry 调用后返回的对象 - const controller = importComponent(componentName) as Controller - controllerAction(controller, controller.getSupportedRates()) - context.showTip(`${controller.videoSpeed()}x`, 'mdi-fast-forward') - } - - addData('keymap.actions', (actions: Record) => { - actions.videoSpeedIncrease = { - displayName: '提高倍速', - run: context => { - videoSpeed(context, (controller, rates) => { - controller.setVideoSpeed( - rates.find(it => it > controller.videoSpeed()) - || rates[rates.length - 1], - ) - }) - return true - }, - } - actions.videoSpeedDecrease = { - displayName: '降低倍速', - run: context => { - videoSpeed(context, (controller, rates) => { - controller.setVideoSpeed( - [...rates].reverse().find(it => it < controller.videoSpeed()) - || rates[0], - ) - }) - return true - }, - } - actions.videoSpeedReset = { - displayName: '重置倍速', - run: context => { - videoSpeed(context, controller => { - controller.toggleVideoSpeed() - }) - return true - }, - } - if (getComponentSettings('rememberVideoSpeed').options.individualRemember) { - actions.videoSpeedForget = { - displayName: '清除当前倍速记忆', - run: context => { - videoSpeed(context, controller => { - controller.resetVideoSpeed(true) - }) - return true - }, - } - } - }) - addData('keymap.presets', (presetBase: Record) => { - presetBase.videoSpeedIncrease = 'shift > 》 arrowUp' - presetBase.videoSpeedDecrease = 'shift < 《 arrowDown' - presetBase.videoSpeedReset = 'shift ? ?' - presetBase.videoSpeedForget = 'shift : :' - }) - }, - }, options: { - speed: { - displayName: '记忆的速度', - defaultValue: '1.0', - hidden: true, + globalSpeed: { + displayName: '全局记忆倍速值', + defaultValue: 1, + validator: val => lodash.clamp( + parseFloat(val), + MIN_BROWSER_SPEED_VALUE, + MAX_BROWSER_SPEED_VALUE, + ) || 1, }, - extend: { - displayName: '扩展倍速菜单', - defaultValue: true, - }, - extendList: { - displayName: '扩展倍速列表', - defaultValue: [2.5, 3], - hidden: true, - }, - remember: { - displayName: '启用倍速记忆', - defaultValue: true, + fixGlobalSpeed: { + displayName: '固定全局倍速值', + defaultValue: false, }, individualRemember: { displayName: '各视频分别记忆', defaultValue: false, - hidden: true, }, - individualRememberList: { - displayName: '分别记忆倍速列表', + individualRememberRecord: { + displayName: '独立记忆倍速记录', defaultValue: {}, hidden: true, }, + showRestoreTip: { + displayName: '弹出还原倍速提示', + defaultValue: true, + }, }, -} +}) diff --git a/registry/lib/components/video/player/remember-speed/utils.ts b/registry/lib/components/video/player/remember-speed/utils.ts deleted file mode 100644 index 52d04781f..000000000 --- a/registry/lib/components/video/player/remember-speed/utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { playerAgent } from '@/components/video/player-agent' -import { ComponentSettings, getComponentSettings } from '@/core/settings' -import { ascendingSort } from '@/core/utils/sort' - -export const minValue = 0.0625 -export const maxValue = 16 -export const stepValue = 0.5 -export const errorMessageDuration = 2000 -export const nativeSupportedRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] - -export const extendedAgent = playerAgent.provideCustomQuery({ - video: { - speedMenuList: '.bilibili-player-video-btn-speed-menu', - speedMenuItem: '.bilibili-player-video-btn-speed-menu-list', - speedNameBtn: '.bilibili-player-video-btn-speed-name', - speedContainer: '.bilibili-player-video-btn-speed', - active: '.bilibili-player-active', - show: '.bilibili-player-speed-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 const trimLeadingDot = (selector: string) => selector.replace(/^\./, '') -export const calcOrder = (value: number) => ((maxValue - value) * 10000).toString() -export const getAid = (aid = unsafeWindow.aid) => { - if (!aid) { - throw new Error('aid is unknown') - } - return aid -} - -export interface Options { - speed: string - extend: boolean - extendList: number[] - remember: boolean - individualRemember: boolean - individualRememberList: Record -} - -export const { options } = getComponentSettings('rememberVideoSpeed') as ComponentSettings - -export const getUniqueAscendingSortList = (values: number[]) => ( - Array.from(new Set(values)).sort(ascendingSort()) -) - -export const getExtendedSupportedRates = () => getUniqueAscendingSortList(options.extendList) - -export const getSupportedRates = () => (options.extend ? [ - ...nativeSupportedRates, - ...getExtendedSupportedRates(), -].sort(ascendingSort()) : nativeSupportedRates) - -export const formatSpeedText = (speed: number) => { - if (speed === 1) { - return '倍速' - } - return Math.trunc(speed) === speed ? `${speed}.0x` : `${speed}x` -} - -type Listener = [string, (ev: E) => any] -const listeners: Map = new Map() - -export const addListener = (element: HTMLElement, listener: Listener) => { - element.addEventListener(...listener) - listeners.set(element, listener) -} - -export const removeListeners = () => listeners.forEach( - (listener, element) => element.removeEventListener(...listener), -) diff --git a/registry/lib/plugins/video/player/speed/index.ts b/registry/lib/plugins/video/player/speed/index.ts new file mode 100644 index 000000000..2ce95064d --- /dev/null +++ b/registry/lib/plugins/video/player/speed/index.ts @@ -0,0 +1,102 @@ +import { Toast } from '@/core/toast' +import type { PluginMetadata } from '@/plugins/plugin' +import type { KeyBindingAction, KeyBindingActionContext } from 'registry/lib/components/utils/keymap/bindings' +import { getSpeedContext, SpeedContext } from '../../../../components/video/player/common/speed/context' +import { formatSpeedText } from '../../../../components/video/player/common/speed/utils' +import type { RememberSpeedComponent } from '../../../../components/video/player/remember-speed/component' + +interface CommonKeyBindingAction { + videoSpeedIncrease: KeyBindingAction + videoSpeedDecrease: KeyBindingAction + videoSpeedToggle: KeyBindingAction +} + +export const plugin: PluginMetadata = { + name: 'speed.keymap', + displayName: '视频倍速 - 快捷键支持', + author: { + name: 'JLoeve', + link: 'https://github.com/LonelySteve', + }, + description: ` + +为操作视频倍速提供快捷键支持: + +- 提高倍速 +- 降低倍速 +- 切换倍速 + +若添加并启用了记忆倍速组件,则还会增加一个快捷键: + +- 清除倍速记忆 + `, + setup: ({ addData, addHook }) => { + const videoSpeedAction = ( + cb: (context: SpeedContext) => Promise | unknown, + ) => async (context: KeyBindingActionContext) => { + const speedContext = await getSpeedContext() + await cb(speedContext) + context.showTip(formatSpeedText(speedContext.videoElement.playbackRate), 'mdi-fast-forward') + return true + } + + // 最基本的支持 + addData('keymap.actions', (actions: Record) => { + Object.assign(actions, { + videoSpeedIncrease: { + displayName: '提高倍速', + run: videoSpeedAction(({ increase }) => increase()), + }, + videoSpeedDecrease: { + displayName: '降低倍速', + run: videoSpeedAction(({ decrease }) => decrease()), + }, + videoSpeedToggle: { + displayName: '切换倍速', + run: videoSpeedAction(({ toggle }) => { toggle() }), + }, + } as CommonKeyBindingAction) + }) + + addData('keymap.presets', (presetBase: Record) => { + presetBase.videoSpeedIncrease = 'shift > 》 arrowUp' + presetBase.videoSpeedDecrease = 'shift < 《 arrowDown' + presetBase.videoSpeedToggle = 'shift ? ?' + }) + + // NOTE: 不能使用像以前一样使用 importComponent, + // 因为插件在组件之前加载,我们期望在插件的加载期间加载组件,这在目前是无法做到的,即使我们可以通过 isComponentEnabled 判断倍速相关的组件是否被启用, + // 但仍然拿不到加载后的实例 + // 解决方法是使用 addHook,这个 API 本质上提供了一种类似事件监听的机制,由于插件在组件之前加载,因此不用担心挂载的钩子不能被组件调用 + + // 对记忆倍速的额外支持 + addHook('speed.component.rememberVideoSpeed', { + after: (component: RememberSpeedComponent) => { + // 这样设计仍然会出现一个问题,当用户禁用记忆倍速功能后,打开后,如果不刷新页面 + // 即使快捷键扩展里有「清除倍速记忆」的快捷键设置,按下相应的快捷键不会起作用 + // 除了刷新页面之外,理论上还可以通过修改「快捷键扩展」的「预设」和「自定义按键」来正确注册处理程序 + addData('keymap.actions', (actions: Record) => { + actions.videoSpeedForget = { + displayName: '清除倍速记忆', + run: lodash.debounce(videoSpeedAction(async () => { + if (!component.settings.enabled) { + Toast.error('组件已禁用,不能清除当前视频倍速记忆值', component.metadata.displayName, 5000) + return + } + if (!component.options.individualRemember) { + Toast.error('选项「各视频分别记忆」已禁用,不能清除当前视频倍速记忆值', component.metadata.displayName, 5000) + return + } + component.forgetSpeed() + await component.resetVideoSpeed() + Toast.success('已清除当前视频倍速记忆值', component.metadata.displayName, 3000) + }), 200), + } + }) + addData('keymap.presets', (presetBase: Record) => { + presetBase.videoSpeedForget = 'shift : :' + }) + }, + }) + }, +} diff --git a/src/client/init.ts b/src/client/init.ts index 7d09013ab..e331b4c10 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -40,6 +40,10 @@ export const init = async () => { window.coreApis = coreApis window.dq = coreApis.utils.dq window.dqa = coreApis.utils.dqa + window.de = coreApis.utils.de + window.des = coreApis.utils.des + window.dea = coreApis.utils.dea + window.deai = coreApis.utils.deai window.none = coreApis.utils.none window.componentsTags = coreApis.componentApis.component.componentsTags diff --git a/src/core/settings/index.ts b/src/core/settings/index.ts index 8d00fd10c..99bebd944 100644 --- a/src/core/settings/index.ts +++ b/src/core/settings/index.ts @@ -9,7 +9,7 @@ export interface ComponentSettings { /** 是否启用此组件 */ enabled: boolean /** 组件选项 */ - options: O + options: O & Record } /** 脚本总设置 */ export interface Settings { diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 2cdb99347..086149bd5 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -62,6 +62,85 @@ export const dqa: DocumentQuerySelectorAll = ( } return Array.from((selectorOrElement as Element).querySelectorAll(bwpVideoFilter(scopedSelector))) } +type DocumentEvaluate = { + (xpathExpression: string): XPathResult + (xpathExpression: string, contextNode: Node): XPathResult + (xpathExpression: string, contextNode: Node, type: number): XPathResult + (xpathExpression: string, contextNode: Node, type: number, result: XPathResult): XPathResult +} +export const de: DocumentEvaluate = ( + xpathExpression: string, + contextNode?: Node, + type?: number, + result?: XPathResult, +) => document.evaluate(xpathExpression, contextNode, null, type, result) +type DocumentEvaluateAll = { + (xpathExpression: string): Node[] + (xpathExpression: string, contextNode: Node): Node[] + (xpathExpression: string, contextNode: Node, order: boolean): Node[] + (xpathExpression: string, contextNode: Node, order: boolean, result: XPathResult): Node[] +} +export const dea: DocumentEvaluateAll = ( + xpathExpression: string, + contextNode?: Node, + order?: boolean, + result?: XPathResult, +) => { + const xpathResult = de( + xpathExpression, + contextNode, + order ? XPathResult.ORDERED_NODE_SNAPSHOT_TYPE : XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, + result, + ) + + return Array.from({ length: xpathResult.snapshotLength }, (_, i) => xpathResult.snapshotItem(i)) +} +type DocumentEvaluateAllIterable = { + (xpathExpression: string): Iterable + (xpathExpression: string, contextNode: Node): Iterable + (xpathExpression: string, contextNode: Node, order: boolean): Iterable + (xpathExpression: string, contextNode: Node, order: boolean, result: XPathResult): Iterable +} +export const deai: DocumentEvaluateAllIterable = ( + xpathExpression: string, + contextNode?: Node, + order?: boolean, + result?: XPathResult, +) => { + const xpathResult = de( + xpathExpression, + contextNode, + order ? XPathResult.ORDERED_NODE_ITERATOR_TYPE : XPathResult.UNORDERED_NODE_ITERATOR_TYPE, + result, + ) + + return { + [Symbol.iterator]: () => ({ + next: () => { + let node = null + do { + node = xpathResult.iterateNext() + return node + ? ({ done: false, value: node } as { done: false, value: Node }) + : ({ done: true } as { done: true, value: any }) + } while (node) + }, + }), + } +} +type DocumentEvaluateSingle = { + (xpathExpression: string): T | null + (xpathExpression: string, contextNode: Node): T | null + (xpathExpression: string, contextNode: Node, result: XPathResult): T | null +} +export const des: DocumentEvaluateSingle = ( + xpathExpression: string, contextNode?: Node, result?: XPathResult, +) => de( + xpathExpression, + contextNode, + XPathResult.FIRST_ORDERED_NODE_TYPE, + result, +).singleNodeValue as (T | null) /** 空函数 */ export const none = () => { // Do nothing