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`
|
`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)
|
**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)
|
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)
|
### [删除视频弹窗](../../registry/dist/components/video/player/remove-popup.js)
|
||||||
`removePlayerPopup`
|
`removePlayerPopup`
|
||||||
@ -960,3 +971,14 @@ by [@wuliic](https://github.com/wullic)
|
|||||||
by [@diannaojiang](https://github.com/diannaojiang)
|
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 { playerUrls } from '@/core/utils/urls'
|
||||||
import { KeyBindingAction, KeyBindingActionContext } from 'registry/lib/components/utils/keymap/bindings'
|
import { MAX_BROWSER_SPEED_VALUE, MIN_BROWSER_SPEED_VALUE } from '../common/speed'
|
||||||
import type { createController } from './controller'
|
import { Options, RememberSpeedComponent } from './component'
|
||||||
|
|
||||||
const componentName = 'rememberVideoSpeed'
|
export const component = RememberSpeedComponent.create<Options>({
|
||||||
|
name: 'rememberVideoSpeed',
|
||||||
type Controller = ReturnType<typeof createController>
|
displayName: '记忆倍速',
|
||||||
|
|
||||||
export const component: ComponentMetadata = {
|
|
||||||
name: componentName,
|
|
||||||
displayName: '倍速增强',
|
|
||||||
author: {
|
author: {
|
||||||
name: 'JLoeve',
|
name: 'JLoeve',
|
||||||
link: 'https://github.com/LonelySteve',
|
link: 'https://github.com/LonelySteve',
|
||||||
},
|
},
|
||||||
description: {
|
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],
|
tags: [componentsTags.video],
|
||||||
urlInclude: playerUrls,
|
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: {
|
options: {
|
||||||
speed: {
|
globalSpeed: {
|
||||||
displayName: '记忆的速度',
|
displayName: '全局记忆倍速值',
|
||||||
defaultValue: '1.0',
|
defaultValue: 1,
|
||||||
hidden: true,
|
validator: val => lodash.clamp(
|
||||||
|
parseFloat(val),
|
||||||
|
MIN_BROWSER_SPEED_VALUE,
|
||||||
|
MAX_BROWSER_SPEED_VALUE,
|
||||||
|
) || 1,
|
||||||
},
|
},
|
||||||
extend: {
|
fixGlobalSpeed: {
|
||||||
displayName: '扩展倍速菜单',
|
displayName: '固定全局倍速值',
|
||||||
defaultValue: true,
|
defaultValue: false,
|
||||||
},
|
|
||||||
extendList: {
|
|
||||||
displayName: '扩展倍速列表',
|
|
||||||
defaultValue: [2.5, 3],
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
remember: {
|
|
||||||
displayName: '启用倍速记忆',
|
|
||||||
defaultValue: true,
|
|
||||||
},
|
},
|
||||||
individualRemember: {
|
individualRemember: {
|
||||||
displayName: '各视频分别记忆',
|
displayName: '各视频分别记忆',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
hidden: true,
|
|
||||||
},
|
},
|
||||||
individualRememberList: {
|
individualRememberRecord: {
|
||||||
displayName: '分别记忆倍速列表',
|
displayName: '独立记忆倍速记录',
|
||||||
defaultValue: {},
|
defaultValue: {},
|
||||||
hidden: true,
|
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.coreApis = coreApis
|
||||||
window.dq = coreApis.utils.dq
|
window.dq = coreApis.utils.dq
|
||||||
window.dqa = coreApis.utils.dqa
|
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.none = coreApis.utils.none
|
||||||
window.componentsTags = coreApis.componentApis.component.componentsTags
|
window.componentsTags = coreApis.componentApis.component.componentsTags
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export interface ComponentSettings<O = { [key: string]: any }> {
|
|||||||
/** 是否启用此组件 */
|
/** 是否启用此组件 */
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
/** 组件选项 */
|
/** 组件选项 */
|
||||||
options: O
|
options: O & Record<string, unknown>
|
||||||
}
|
}
|
||||||
/** 脚本总设置 */
|
/** 脚本总设置 */
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
|
|||||||
@ -62,6 +62,85 @@ export const dqa: DocumentQuerySelectorAll = (
|
|||||||
}
|
}
|
||||||
return Array.from((selectorOrElement as Element).querySelectorAll(bwpVideoFilter(scopedSelector)))
|
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 = () => {
|
export const none = () => {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user