mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
feat: 从「倍速记忆」/「倍速增强」中分离出「扩展倍速」组件和「视频倍速 - 快捷键支持」插件
* 【新增】支持固定全局倍速值和设置倍速菜单最大高度(后者不显示选项) * 【新增】支持隐藏倍速菜单滚动条 * 【优化】改变选项实时更新 * 【优化】使用了更稳妥的方式获取菜单项元素 * 【优化】使用自实现的 mini-rxjs 处理事件流,避免重复操作,实现更改选项值实时响应 * 【优化】为切换倍速等操作加上了防抖处理 * 【修复】添加空倍速会报倍速值太小的错误信息 * 【迁移】添加了迁移提示和自动迁移配置的功能 * 【更名】「倍速记忆」/「倍速增强」->「记忆倍速」 * 【更名】「扩展倍速菜单」->「扩展倍速」
This commit is contained in:
parent
b7626e3cbf
commit
8c8bc9f45a
@ -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)
|
||||
为下载视频增加 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)
|
||||
|
||||
为操作视频倍速提供快捷键支持,同时适配「扩展倍速」,联动「记忆倍速」并提供「清除倍速记忆」功能.
|
||||
|
||||
@ -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<Event>(({ next }) => {
|
||||
element.addEventListener(eventName, next)
|
||||
return () => element.removeEventListener(eventName, next)
|
||||
})
|
||||
|
||||
export const fromPromise = <T>(promise: Promise<T>) => subject<T>(({ next, complete, error }) => {
|
||||
promise
|
||||
.then(next)
|
||||
.catch(error)
|
||||
.finally(complete)
|
||||
})
|
||||
|
||||
export const bindCallback = <T>(cb: (...args: any[]) => any, ...args_: any[]) => subject<T>(
|
||||
({ next }) => {
|
||||
cb(...args_, next)
|
||||
},
|
||||
)
|
||||
@ -0,0 +1,3 @@
|
||||
export * from './create'
|
||||
export * from './subject'
|
||||
export * from './utils'
|
||||
@ -0,0 +1,17 @@
|
||||
import { PublishContext } from '../subject'
|
||||
|
||||
export const bufferSet = <T>(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() }
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { subject, Subject } from '../subject'
|
||||
import { asapScheduler, observeOn } from './observeOn'
|
||||
import { withTeardownLogic } from './util'
|
||||
|
||||
export const combineLatest = (...input: Subject<unknown>[]) => 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
|
||||
})
|
||||
}),
|
||||
)
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { PublishContext } from '../subject'
|
||||
|
||||
export const filter = <T>(predicate: (value: T) => boolean) => ({
|
||||
subscribe,
|
||||
next,
|
||||
}: PublishContext<T>) => {
|
||||
subscribe(value => {
|
||||
predicate(value) && next(value)
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { PublishContext } from '../subject'
|
||||
|
||||
export const map = <T = unknown, R = T>(mapper: (value: T) => R) => ({
|
||||
subscribe,
|
||||
next,
|
||||
}: PublishContext<T, R>) => {
|
||||
subscribe(value => {
|
||||
next(mapper(value))
|
||||
})
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { PublishContext } from '../subject'
|
||||
|
||||
export const pairwise = <T = unknown>() => ({
|
||||
subscribe,
|
||||
next,
|
||||
}: Pick<PublishContext<T, T[]>, '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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { Observer, PublishContext, toStandardizedObserver } from '../subject'
|
||||
|
||||
export const tap = <T>(observer: Observer<T>) => ({
|
||||
subscribe,
|
||||
next,
|
||||
error,
|
||||
complete,
|
||||
}: PublishContext<T>) => {
|
||||
const standardizedObserver = toStandardizedObserver(observer)
|
||||
|
||||
subscribe(
|
||||
lodash.mapValues(
|
||||
{ next, error, complete },
|
||||
(v, k) => (value?: T | Error) => {
|
||||
standardizedObserver[k]?.(value)
|
||||
v.call(null, value)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import { TeardownLogic } from '../../subject'
|
||||
|
||||
export const withTeardownLogic = (
|
||||
cb: (
|
||||
addTeardownLogic: (teardownLogicList: (TeardownLogic | TeardownLogic[] | any)) => void
|
||||
) => void,
|
||||
) => {
|
||||
const teardownLogicSet = new Set<TeardownLogic>()
|
||||
|
||||
cb(teardownLogicList => {
|
||||
lodash.castArray(teardownLogicList).forEach(teardownLogic => {
|
||||
teardownLogicSet.add(teardownLogic)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
teardownLogicSet.forEach(teardownLogic => {
|
||||
teardownLogic()
|
||||
})
|
||||
}
|
||||
}
|
||||
142
registry/lib/components/video/player/common/mini-rxjs/subject.ts
Normal file
142
registry/lib/components/video/player/common/mini-rxjs/subject.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { getGeneralSettings } from '@/core/settings'
|
||||
|
||||
export type EmptyFunction = () => void
|
||||
export type TeardownLogic = EmptyFunction
|
||||
export type Unsubscribe = EmptyFunction
|
||||
|
||||
export interface StandardizedObserver<T = any> {
|
||||
next(value?: T): void
|
||||
error?(err: Error): void
|
||||
complete?(): void
|
||||
}
|
||||
|
||||
export type Observer<T = any> = ((value: T) => void) | StandardizedObserver<T>
|
||||
|
||||
export interface PublishContext<T = any, R = T>
|
||||
extends Required<Omit<StandardizedObserver<R>, 'completed'>> {
|
||||
subscribe?(observer?: Observer<T>): Unsubscribe | undefined
|
||||
}
|
||||
|
||||
export interface Publisher<T = any, R = T> {
|
||||
(context: PublishContext<T, R>): TeardownLogic | void
|
||||
}
|
||||
|
||||
export interface Operator<T = any, R = T> {
|
||||
(context: PublishContext<T, R>): TeardownLogic | void
|
||||
}
|
||||
|
||||
export interface Subject<T> extends Required<PublishContext<T>> {
|
||||
connect(): void
|
||||
pipe<R = T>(...operators: Operator[]): Subject<R>
|
||||
}
|
||||
|
||||
export const toStandardizedObserver = <T>(
|
||||
observer: Observer<T>,
|
||||
): StandardizedObserver<T> => (typeof observer === 'function' ? { next: observer } : observer)
|
||||
|
||||
export const subject = <T, R = T>(
|
||||
publisher?: Publisher<T, R>,
|
||||
): Subject<R> => (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<T>) => {
|
||||
if (observer == null) {
|
||||
return null
|
||||
}
|
||||
observers.push(observer)
|
||||
return () => {
|
||||
lodash.pull(observers, observer)
|
||||
}
|
||||
}
|
||||
|
||||
const pipe = (...publishers: Publisher<any>[]) => {
|
||||
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))
|
||||
@ -0,0 +1,14 @@
|
||||
import { Subject as SubjectReturnType } from './subject'
|
||||
|
||||
export const firstValueFrom = <T>(subject: SubjectReturnType<T>) => new Promise<T>(
|
||||
(resolve, reject) => {
|
||||
const unsubscribe = subject.subscribe({
|
||||
next: (value:T) => {
|
||||
resolve(value)
|
||||
unsubscribe()
|
||||
},
|
||||
error: () => { reject(); unsubscribe() },
|
||||
complete: () => { reject(); unsubscribe() },
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -0,0 +1,14 @@
|
||||
import { Subject } from '../subject'
|
||||
|
||||
export const firstValueFrom = <T>(subject: Subject<T>) => new Promise<T>(
|
||||
(resolve, reject) => {
|
||||
const unsubscribe = subject.subscribe({
|
||||
next: (value:T) => {
|
||||
resolve(value)
|
||||
unsubscribe()
|
||||
},
|
||||
error: () => { reject(); unsubscribe() },
|
||||
complete: () => { reject(); unsubscribe() },
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -0,0 +1,26 @@
|
||||
import { addStyle } from '@/core/style'
|
||||
|
||||
export interface LoadStyleOptions<D> {
|
||||
style: string | ((dep: D) => any)
|
||||
name?: string
|
||||
container?: HTMLElement
|
||||
}
|
||||
|
||||
export const loadStyle = <D>(
|
||||
{ style, name, container }: LoadStyleOptions<D>,
|
||||
) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
216
registry/lib/components/video/player/common/speed.ts
Normal file
216
registry/lib/components/video/player/common/speed.ts
Normal file
@ -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<ComponentEntry>[0]
|
||||
|
||||
export type OptionSubjects<O> = O & { [K in keyof O as `${Exclude<K, symbol>}$`]: Subject<O[K]> }
|
||||
|
||||
export class EntrySpeedComponent<O = Record<string, unknown>>
|
||||
implements EntryContext {
|
||||
static create: <
|
||||
OO extends Record<string, any> = unknown
|
||||
>(metadata: Omit<
|
||||
ComponentMetadata,
|
||||
'entry' | 'reload' | 'unload' | 'options'
|
||||
> & {
|
||||
options?: { [K in keyof OO]: ComponentOption }
|
||||
}) => ComponentMetadata;
|
||||
|
||||
static contextMap: Partial<Record<keyof EntrySpeedComponent, keyof SpeedContext | string>> = {
|
||||
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<O>
|
||||
coreApis: CoreApis
|
||||
metadata: ComponentMetadata
|
||||
options: OptionSubjects<O>
|
||||
readonly getVideoIdObject: () => Promise<VideoIdObject>
|
||||
readonly getAvailableSpeedValues: () => Promise<number[]>
|
||||
readonly getOldActiveVideoSpeed: () => Promise<number | undefined>
|
||||
readonly forceVideoSpeed: (value: number) => Promise<void>
|
||||
readonly getVideoSpeed: () => Promise<number>
|
||||
readonly setVideoSpeed: (value: number, timeout?: number) => Promise<void>
|
||||
readonly resetVideoSpeed: () => Promise<void>
|
||||
readonly toggleVideoSpeed:
|
||||
(legacy?: boolean) => Promise<void>
|
||||
| ((offset: number, whence?: SpeedSeekPosition) => Promise<void>)
|
||||
readonly increaseVideoSpeed: () => Promise<void>
|
||||
readonly decreaseVideoSpeed: () => Promise<void>
|
||||
|
||||
getSpeedContextMixin?(context: SpeedContext): Partial<SpeedContext>
|
||||
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<unknown>[] = []
|
||||
|
||||
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<OptionSubjects<unknown>>(
|
||||
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<unknown>]) => {
|
||||
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<boolean>().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),
|
||||
}
|
||||
}
|
||||
557
registry/lib/components/video/player/common/speed/context.ts
Normal file
557
registry/lib/components/video/player/common/speed/context.ts
Normal file
@ -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<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.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<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$)
|
||||
}
|
||||
46
registry/lib/components/video/player/common/speed/utils.ts
Normal file
46
registry/lib/components/video/player/common/speed/utils.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { getData, registerAndGetData } from '@/plugins/data'
|
||||
|
||||
export const trimLeadingDot = (selector: string) => selector.replace(/^\./, '')
|
||||
|
||||
export const useShare = <T>(
|
||||
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}`)
|
||||
}
|
||||
486
registry/lib/components/video/player/extend-speed/component.ts
Normal file
486
registry/lib/components/video/player/extend-speed/component.ts
Normal file
@ -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<N extends Node, T = any> {
|
||||
tag?: T
|
||||
node: N
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
const $ = <E extends Record<string, Element>, R extends Element = Element>(
|
||||
html: string, scoped = true,
|
||||
) => {
|
||||
const containerElement = document.createElement('div')
|
||||
containerElement.innerHTML = html
|
||||
const result = {}
|
||||
const root = containerElement.children.item(0)
|
||||
const walkChildren = (element: Element) => {
|
||||
if (scoped) {
|
||||
element.id = `scoped-element-${Math.random().toString(36).replace(/[^a-z0-9]+/g, '')}`
|
||||
}
|
||||
const dataRef = element.getAttribute('data-ref')
|
||||
if (dataRef) {
|
||||
result[lodash.camelCase(dataRef)] = element
|
||||
}
|
||||
for (let i = 0; i < element.children.length; i++) {
|
||||
walkChildren(element.children.item(i))
|
||||
}
|
||||
}
|
||||
walkChildren(root)
|
||||
return { ...result, root } as (E & { root: R, [index: string]: Element })
|
||||
}
|
||||
|
||||
export class ExtendSpeedComponent extends EntrySpeedComponent<Options> {
|
||||
protected static get activeClassName() {
|
||||
return trimLeadingDot(
|
||||
PLAYER_AGENT.custom.active.selector,
|
||||
)
|
||||
}
|
||||
|
||||
protected static get showClassName() {
|
||||
return trimLeadingDot(
|
||||
PLAYER_AGENT.custom.show.selector,
|
||||
)
|
||||
}
|
||||
|
||||
protected static get speedMenuItemClassName() {
|
||||
return trimLeadingDot(
|
||||
PLAYER_AGENT.custom.speedMenuItem.selector,
|
||||
)
|
||||
}
|
||||
|
||||
addSpeedValue(value: number) {
|
||||
this.options.extendSpeedList = lodash.sortedUniq(
|
||||
this.options.extendSpeedList.concat(value).sort(ascendingSort()),
|
||||
)
|
||||
}
|
||||
|
||||
removeSpeedValue(value: number) {
|
||||
this.options.extendSpeedList = lodash.without(
|
||||
this.options.extendSpeedList,
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
createInputElement(): VNode<HTMLLIElement> {
|
||||
const { input, root, icon } = $<{ input: HTMLInputElement, icon: HTMLElement }, HTMLLIElement>(`
|
||||
<li class="${trimLeadingDot(PLAYER_AGENT.custom.speedMenuItem.selector)} ${EXTEND_SPEED_INPUT_CLASS_NAME}">
|
||||
<i data-ref="icon" class="mdi mdi-playlist-plus" style="font-size: 1.5em"></i>
|
||||
<input data-ref="input" type="number" title="添加新的倍数值" max="${MAX_BROWSER_SPEED_VALUE}" step="${STEP_SPEED_VALUE}" style="display: none;"></input>
|
||||
</li>
|
||||
`)
|
||||
|
||||
const updateRecommendedValue = () => {
|
||||
const value = this.speedContext.getAvailableSpeedValues().slice(-1)[0] + STEP_SPEED_VALUE
|
||||
const recommendedValue = lodash.toString(value > MAX_BROWSER_SPEED_VALUE ? null : value)
|
||||
input.value = recommendedValue
|
||||
input.min = recommendedValue
|
||||
}
|
||||
|
||||
this.options.extendSpeedList$.pipe(observeOn(asapScheduler)).subscribe(updateRecommendedValue)
|
||||
|
||||
const styleElement = addStyle(`
|
||||
#${input.id} {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
line-height: inherit;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
border: none;
|
||||
text-align: center;
|
||||
cursor: text;
|
||||
}
|
||||
/* https://stackoverflow.com/a/4298216 */
|
||||
/* Chrome */
|
||||
#${input.id}::-webkit-outer-spin-button,
|
||||
#${input.id}::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
/* Firefox */
|
||||
#${input.id}[type=number] {
|
||||
-moz-appearance:textfield;
|
||||
}
|
||||
`)
|
||||
|
||||
const destroy = lodash.over(
|
||||
fromEvent(input, 'keydown')
|
||||
.pipe<KeyboardEvent>(debounceTime(200))
|
||||
.subscribe(({ key }) => {
|
||||
if (key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
||||
const value = parseFloat(input.value)
|
||||
|
||||
try {
|
||||
if (!lodash.isFinite(value)) {
|
||||
throw new Error('无效的倍数值')
|
||||
}
|
||||
if (value < MIN_BROWSER_SPEED_VALUE) {
|
||||
throw new Error('倍数值太小了')
|
||||
}
|
||||
if (value > MAX_BROWSER_SPEED_VALUE) {
|
||||
throw new Error('倍数值太大了')
|
||||
}
|
||||
if (this.speedContext.getAvailableSpeedValues().includes(value)) {
|
||||
throw new Error('不能重复添加已有的倍数值')
|
||||
}
|
||||
this.addSpeedValue(value)
|
||||
} catch (error) {
|
||||
logError(String(error), ERROR_MESSAGE_DURATION)
|
||||
input.focus()
|
||||
input.select()
|
||||
}
|
||||
}),
|
||||
fromEvent(root, 'mouseenter').subscribe(() => {
|
||||
input.style.display = 'inline'
|
||||
icon.style.display = 'none'
|
||||
updateRecommendedValue()
|
||||
setTimeout(() => input.focus())
|
||||
}),
|
||||
fromEvent(root, 'mouseleave').subscribe(() => {
|
||||
input.style.display = 'none'
|
||||
icon.style.display = 'inline'
|
||||
}),
|
||||
() => root.remove(),
|
||||
() => styleElement.remove(),
|
||||
)
|
||||
|
||||
return {
|
||||
node: root,
|
||||
destroy,
|
||||
}
|
||||
}
|
||||
|
||||
createCustomSpeedMenuItemElement(
|
||||
value: number,
|
||||
): VNode<HTMLLIElement, number> {
|
||||
const { closeBtn, root } = $<{ closeBtn: HTMLElement }, HTMLLIElement>(`
|
||||
<li class="${trimLeadingDot(PLAYER_AGENT.custom.speedMenuItem.selector)} ${EXTEND_SPEED_ITEM_CLASS_NAME}">
|
||||
${formatSpeedText(value)}
|
||||
<i data-ref="close-btn" class="mdi mdi-close-circle"></i>
|
||||
</li>
|
||||
`)
|
||||
|
||||
const styleElement = addStyle(`
|
||||
.${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"] {
|
||||
color: inherit;
|
||||
opacity: 0.5;
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
}
|
||||
${PLAYER_AGENT.custom.speedMenuItem.selector}:not(${PLAYER_AGENT.custom.active.selector}):hover [data-ref="close-btn"] {
|
||||
display: inline;
|
||||
}
|
||||
.${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"]:hover {
|
||||
opacity: 1;
|
||||
transition: all .3s;
|
||||
}
|
||||
`)
|
||||
|
||||
const destroy = lodash.over(
|
||||
fromEvent(closeBtn, 'click').subscribe(() => {
|
||||
this.removeSpeedValue(value)
|
||||
}),
|
||||
() => root.remove(),
|
||||
() => styleElement.remove(),
|
||||
)
|
||||
|
||||
return {
|
||||
tag: value,
|
||||
node: root,
|
||||
destroy,
|
||||
}
|
||||
}
|
||||
|
||||
protected elementMap: VNode<HTMLLIElement>[] = [];
|
||||
protected inputElement: VNode<HTMLLIElement>
|
||||
protected unpatch: TeardownLogic
|
||||
|
||||
protected migrate() {
|
||||
const { options } = this.settings
|
||||
const { options: rememberSpeedOptions } = getComponentSettings('rememberVideoSpeed') as ComponentSettings<RememberSpeedOptions>
|
||||
if (rememberSpeedOptions.extendList) {
|
||||
options.extendSpeedList = Array.from(rememberSpeedOptions.extendList as number[])
|
||||
delete rememberSpeedOptions.extendList
|
||||
delete rememberSpeedOptions.extend
|
||||
Toast.success('从「倍速记忆」组件迁移旧配置成功', '【扩展倍速】旧配置迁移完成', 8e3)
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
getSpeedContextMixin({ menuListElement }: SpeedContext): Partial<SpeedContext> {
|
||||
return {
|
||||
query: (speed: number) => des<HTMLElement>(
|
||||
// 有的时候,选择 1.0x 倍速,会错误选中 11.0x 倍速
|
||||
// 删掉 11.0x 倍速后,再选择 1.0x 倍速就没出现该问题,这是因为之前的实现使用 contains 函数判断,由于 11.0x 倍速文本包含 1.0x,所以被误判了
|
||||
// 原先使用 contains 而不直接比较的原因是,自定义的倍速菜单项,可能在创建时引入了多余的空白符,现在使用 normalize-space 代替之前的做法
|
||||
`./*[contains(@class, "${ExtendSpeedComponent.speedMenuItemClassName}")`
|
||||
+ ` 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<number>) => {
|
||||
subscribe(currentSpeed => {
|
||||
if (NATIVE_SUPPORTED_VALUES.includes(currentSpeed)) {
|
||||
next(currentSpeed)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
protected patch(params: [number, number, number[]]) {
|
||||
const [start, deleteCount, added] = params
|
||||
const { menuListElement } = this.speedContext
|
||||
if (!this.inputElement) {
|
||||
this.inputElement = this.createInputElement()
|
||||
menuListElement.prepend(this.inputElement.node)
|
||||
}
|
||||
|
||||
if (deleteCount === 0 && added.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const addedElements = added.map(v => this.createCustomSpeedMenuItemElement(v))
|
||||
|
||||
const deleted = this.elementMap.splice(
|
||||
start,
|
||||
deleteCount,
|
||||
...addedElements,
|
||||
)
|
||||
|
||||
deleted.forEach(vnode => {
|
||||
vnode.destroy()
|
||||
});
|
||||
|
||||
((this.elementMap[start - 1] || this.inputElement).node)
|
||||
.after(...addedElements.map(el => el.node).reverse())
|
||||
|
||||
// 为所有倍速菜单项刷新 Order
|
||||
menuListElement
|
||||
.querySelectorAll(
|
||||
`${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<HTMLElement>) {
|
||||
element.classList.remove(ExtendSpeedComponent.activeClassName)
|
||||
}
|
||||
// 对于被强制更新的菜单项,添加激活态的类名
|
||||
querySpeedMenuItemElement(value).classList.add(ExtendSpeedComponent.activeClassName)
|
||||
// 关闭菜单
|
||||
containerElement.classList.remove(ExtendSpeedComponent.showClassName)
|
||||
// 更新倍速菜单按钮文本
|
||||
nameBtnElement.innerText = formatSpeedText(
|
||||
value,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
56
registry/lib/components/video/player/extend-speed/index.ts
Normal file
56
registry/lib/components/video/player/extend-speed/index.ts
Normal file
@ -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<Options>({
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
221
registry/lib/components/video/player/remember-speed/component.ts
Normal file
221
registry/lib/components/video/player/remember-speed/component.ts
Normal file
@ -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<string, (string | number)[]>
|
||||
/** 弹出还原倍速提示 */
|
||||
showRestoreTip: boolean
|
||||
}
|
||||
|
||||
export class RememberSpeedComponent extends EntrySpeedComponent<Options> {
|
||||
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<SpeedContext> {
|
||||
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<string, (number | string)[]>
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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<typeof createController>
|
||||
|
||||
export const component: ComponentMetadata = {
|
||||
name: componentName,
|
||||
displayName: '倍速增强',
|
||||
export const component = RememberSpeedComponent.create<Options>({
|
||||
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<string, KeyBindingAction>) => {
|
||||
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<string, string>) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@ -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<string, string[]>
|
||||
}
|
||||
|
||||
export const { options } = getComponentSettings('rememberVideoSpeed') as ComponentSettings<Options>
|
||||
|
||||
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<E extends Event = Event> = [string, (ev: E) => any]
|
||||
const listeners: Map<HTMLElement, Listener> = 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),
|
||||
)
|
||||
102
registry/lib/plugins/video/player/speed/index.ts
Normal file
102
registry/lib/plugins/video/player/speed/index.ts
Normal file
@ -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> | 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<string, KeyBindingAction>) => {
|
||||
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<keyof CommonKeyBindingAction, string>) => {
|
||||
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<string, KeyBindingAction>) => {
|
||||
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<string, string>) => {
|
||||
presetBase.videoSpeedForget = 'shift : :'
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export interface ComponentSettings<O = { [key: string]: any }> {
|
||||
/** 是否启用此组件 */
|
||||
enabled: boolean
|
||||
/** 组件选项 */
|
||||
options: O
|
||||
options: O & Record<string, unknown>
|
||||
}
|
||||
/** 脚本总设置 */
|
||||
export interface Settings {
|
||||
|
||||
@ -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<Node>
|
||||
(xpathExpression: string, contextNode: Node): Iterable<Node>
|
||||
(xpathExpression: string, contextNode: Node, order: boolean): Iterable<Node>
|
||||
(xpathExpression: string, contextNode: Node, order: boolean, result: XPathResult): Iterable<Node>
|
||||
}
|
||||
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 = {
|
||||
<T extends Node>(xpathExpression: string): T | null
|
||||
<T extends Node>(xpathExpression: string, contextNode: Node): T | null
|
||||
<T extends Node>(xpathExpression: string, contextNode: Node, result: XPathResult): T | null
|
||||
}
|
||||
export const des: DocumentEvaluateSingle = <T extends Node>(
|
||||
xpathExpression: string, contextNode?: Node, result?: XPathResult,
|
||||
) => de(
|
||||
xpathExpression,
|
||||
contextNode,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
result,
|
||||
).singleNodeValue as (T | null)
|
||||
/** 空函数 */
|
||||
export const none = () => {
|
||||
// Do nothing
|
||||
|
||||
Loading…
Reference in New Issue
Block a user