feat: 从「倍速记忆」/「倍速增强」中分离出「扩展倍速」组件和「视频倍速 - 快捷键支持」插件

* 【新增】支持固定全局倍速值和设置倍速菜单最大高度(后者不显示选项)
* 【新增】支持隐藏倍速菜单滚动条
* 【优化】改变选项实时更新
* 【优化】使用了更稳妥的方式获取菜单项元素
* 【优化】使用自实现的 mini-rxjs 处理事件流,避免重复操作,实现更改选项值实时响应
* 【优化】为切换倍速等操作加上了防抖处理
* 【修复】添加空倍速会报倍速值太小的错误信息
* 【迁移】添加了迁移提示和自动迁移配置的功能
* 【更名】「倍速记忆」/「倍速增强」->「记忆倍速」
* 【更名】「扩展倍速菜单」->「扩展倍速」
This commit is contained in:
JLoeve 2022-03-16 22:39:28 +08:00
parent b7626e3cbf
commit 8c8bc9f45a
32 changed files with 2255 additions and 666 deletions

View File

@ -770,7 +770,7 @@ by [@FoundTheWOUT](https://github.com/FoundTheWOUT)
在视频播放器网页全屏时, 即使宽度过小也强制保留弹幕发送栏, 注意这可能导致右侧的功能按钮挤出边界.
### [倍速增强](../../registry/dist/components/video/player/remember-speed.js)
### [记忆倍速](../../registry/dist/components/video/player/remember-speed.js)
`rememberVideoSpeed`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/player/remember-speed.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/video/player/remember-speed.js)
@ -779,7 +779,18 @@ by [@FoundTheWOUT](https://github.com/FoundTheWOUT)
by [@JLoeve](https://github.com/LonelySteve)
可以记忆上次选择的视频播放速度, 还可以使用更多倍速来扩展原生倍速菜单.
提高视频播放器的倍速记忆体验,可实现跨页共享倍速,也可以按视频分别记忆倍速.
### [扩展倍速](../../registry/dist/components/video/player/extend-speed.js)
`extendVideoSpeed`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/player/extend-speed.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/video/player/extend-speed.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/video/player/extend-speed.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/video/player/extend-speed.js)
by [@JLoeve](https://github.com/LonelySteve)
扩展视频播放器的倍速菜单项,可用于突破原有播放倍数的上限或下限.
### [删除视频弹窗](../../registry/dist/components/video/player/remove-popup.js)
`removePlayerPopup`
@ -959,4 +970,15 @@ by [@wuliic](https://github.com/wullic)
by [@diannaojiang](https://github.com/diannaojiang)
为下载视频增加 MPV 输出支持, 配置方式请参考 [Bilibili-Playin-Mpv](https://github.com/diannaojiang/Bilibili-Playin-Mpv)
为下载视频增加 MPV 输出支持, 配置方式请参考 [Bilibili-Playin-Mpv](https://github.com/diannaojiang/Bilibili-Playin-Mpv)
### [视频倍速 - 快捷键支持](../../registry/dist/plugins/video/player/speed.js)
`speed.keymap`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/player/speed.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/plugins/video/player/speed.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/plugins/video/player/speed.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/plugins/video/player/speed.js)
by [@JLoeve](https://github.com/LonelySteve)
为操作视频倍速提供快捷键支持,同时适配「扩展倍速」,联动「记忆倍速」并提供「清除倍速记忆」功能.

View File

@ -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)
},
)

View File

@ -0,0 +1,3 @@
export * from './create'
export * from './subject'
export * from './utils'

View File

@ -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() }
}

View File

@ -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
})
}),
)

View File

@ -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),
)
}

View File

@ -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)
}
})
}

View File

@ -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)
})
}

View File

@ -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))
})
}

View File

@ -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))
}

View File

@ -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
}
}

View File

@ -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
})
}

View File

@ -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)
},
),
)
}

View File

@ -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()
})
}
}

View 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))

View File

@ -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() },
})
},
)

View File

@ -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() },
})
},
)

View File

@ -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,
}
}

View 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),
}
}

View 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$)
}

View 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}`)
}

View 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,
)
}
}

View 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,
},
},
})

View 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()
}
}

View File

@ -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,
}
})

View File

@ -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
}

View File

@ -1,125 +1,66 @@
import { importComponent } from '@/components/component'
import { ComponentMetadata } from '@/components/types'
import { playerUrls } from '@/core/utils/urls'
import { KeyBindingAction, KeyBindingActionContext } from 'registry/lib/components/utils/keymap/bindings'
import type { createController } from './controller'
import { MAX_BROWSER_SPEED_VALUE, MIN_BROWSER_SPEED_VALUE } from '../common/speed'
import { Options, RememberSpeedComponent } from './component'
const componentName = 'rememberVideoSpeed'
type Controller = ReturnType<typeof createController>
export const component: ComponentMetadata = {
name: componentName,
displayName: '倍速增强',
export const component = RememberSpeedComponent.create<Options>({
name: 'rememberVideoSpeed',
displayName: '记忆倍速',
author: {
name: 'JLoeve',
link: 'https://github.com/LonelySteve',
},
description: {
'zh-CN': '可以记忆上次选择的视频播放速度, 还可以使用更多倍速来扩展原生倍速菜单.',
'zh-CN': `
> .
### 🔧 ****
- \`全局记忆倍速值\`:默认情况下,这是跨页共享的倍速值,如果启用「各视频分别记忆」,则作为从未独立记忆倍速视频的初始倍速值.
- \`固定全局倍速值\`:默认情况下,全局倍速值将随着用户改变视频倍速而改变,打开此选项后,全局记忆倍速值不再受倍速调整的影响.
- \`各视频分别记忆\`:打开此选项后,将按不同视频分别记忆倍速,对于从未被记忆过倍速的视频,将采用全局记忆倍速值,选项「固定全局倍速值」在此情况下强制生效.
- \`弹出还原倍速提示\`:打开此选项后,每次成功还原倍速后都会弹出提示.
### 🌈 ****
.
- [](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/video/player/extend-speed.js)组件
- [ - ](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/plugins/video/player/speed.js)插件.
* - .*
`,
},
tags: [componentsTags.video],
urlInclude: playerUrls,
entry: async () => (await import('./controller')).createController(),
plugin: {
displayName: '倍速增强 - 快捷键支持',
setup: async ({ addData }) => {
const { getComponentSettings } = await import('@/core/settings')
const videoSpeed = async (
context: KeyBindingActionContext,
controllerAction: (
controller: Controller, rates: number[]
) => void,
) => {
// 不要提前导入,插件在组件加载之前进行加载,因此如果提前加载会取不到 entry 调用后返回的对象
const controller = importComponent(componentName) as Controller
controllerAction(controller, controller.getSupportedRates())
context.showTip(`${controller.videoSpeed()}x`, 'mdi-fast-forward')
}
addData('keymap.actions', (actions: Record<string, KeyBindingAction>) => {
actions.videoSpeedIncrease = {
displayName: '提高倍速',
run: context => {
videoSpeed(context, (controller, rates) => {
controller.setVideoSpeed(
rates.find(it => it > controller.videoSpeed())
|| rates[rates.length - 1],
)
})
return true
},
}
actions.videoSpeedDecrease = {
displayName: '降低倍速',
run: context => {
videoSpeed(context, (controller, rates) => {
controller.setVideoSpeed(
[...rates].reverse().find(it => it < controller.videoSpeed())
|| rates[0],
)
})
return true
},
}
actions.videoSpeedReset = {
displayName: '重置倍速',
run: context => {
videoSpeed(context, controller => {
controller.toggleVideoSpeed()
})
return true
},
}
if (getComponentSettings('rememberVideoSpeed').options.individualRemember) {
actions.videoSpeedForget = {
displayName: '清除当前倍速记忆',
run: context => {
videoSpeed(context, controller => {
controller.resetVideoSpeed(true)
})
return true
},
}
}
})
addData('keymap.presets', (presetBase: Record<string, string>) => {
presetBase.videoSpeedIncrease = 'shift > 》 arrowUp'
presetBase.videoSpeedDecrease = 'shift < 《 arrowDown'
presetBase.videoSpeedReset = 'shift ? '
presetBase.videoSpeedForget = 'shift : '
})
},
},
options: {
speed: {
displayName: '记忆的速度',
defaultValue: '1.0',
hidden: true,
globalSpeed: {
displayName: '全局记忆倍速值',
defaultValue: 1,
validator: val => lodash.clamp(
parseFloat(val),
MIN_BROWSER_SPEED_VALUE,
MAX_BROWSER_SPEED_VALUE,
) || 1,
},
extend: {
displayName: '扩展倍速菜单',
defaultValue: true,
},
extendList: {
displayName: '扩展倍速列表',
defaultValue: [2.5, 3],
hidden: true,
},
remember: {
displayName: '启用倍速记忆',
defaultValue: true,
fixGlobalSpeed: {
displayName: '固定全局倍速值',
defaultValue: false,
},
individualRemember: {
displayName: '各视频分别记忆',
defaultValue: false,
hidden: true,
},
individualRememberList: {
displayName: '分别记忆倍速列表',
individualRememberRecord: {
displayName: '独立记忆倍速记录',
defaultValue: {},
hidden: true,
},
showRestoreTip: {
displayName: '弹出还原倍速提示',
defaultValue: true,
},
},
}
})

View File

@ -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),
)

View 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 : '
})
},
})
},
}

View File

@ -40,6 +40,10 @@ export const init = async () => {
window.coreApis = coreApis
window.dq = coreApis.utils.dq
window.dqa = coreApis.utils.dqa
window.de = coreApis.utils.de
window.des = coreApis.utils.des
window.dea = coreApis.utils.dea
window.deai = coreApis.utils.deai
window.none = coreApis.utils.none
window.componentsTags = coreApis.componentApis.component.componentsTags

View File

@ -9,7 +9,7 @@ export interface ComponentSettings<O = { [key: string]: any }> {
/** 是否启用此组件 */
enabled: boolean
/** 组件选项 */
options: O
options: O & Record<string, unknown>
}
/** 脚本总设置 */
export interface Settings {

View File

@ -62,6 +62,85 @@ export const dqa: DocumentQuerySelectorAll = (
}
return Array.from((selectorOrElement as Element).querySelectorAll(bwpVideoFilter(scopedSelector)))
}
type DocumentEvaluate = {
(xpathExpression: string): XPathResult
(xpathExpression: string, contextNode: Node): XPathResult
(xpathExpression: string, contextNode: Node, type: number): XPathResult
(xpathExpression: string, contextNode: Node, type: number, result: XPathResult): XPathResult
}
export const de: DocumentEvaluate = (
xpathExpression: string,
contextNode?: Node,
type?: number,
result?: XPathResult,
) => document.evaluate(xpathExpression, contextNode, null, type, result)
type DocumentEvaluateAll = {
(xpathExpression: string): Node[]
(xpathExpression: string, contextNode: Node): Node[]
(xpathExpression: string, contextNode: Node, order: boolean): Node[]
(xpathExpression: string, contextNode: Node, order: boolean, result: XPathResult): Node[]
}
export const dea: DocumentEvaluateAll = (
xpathExpression: string,
contextNode?: Node,
order?: boolean,
result?: XPathResult,
) => {
const xpathResult = de(
xpathExpression,
contextNode,
order ? XPathResult.ORDERED_NODE_SNAPSHOT_TYPE : XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
result,
)
return Array.from({ length: xpathResult.snapshotLength }, (_, i) => xpathResult.snapshotItem(i))
}
type DocumentEvaluateAllIterable = {
(xpathExpression: string): Iterable<Node>
(xpathExpression: string, contextNode: Node): Iterable<Node>
(xpathExpression: string, contextNode: Node, order: boolean): Iterable<Node>
(xpathExpression: string, contextNode: Node, order: boolean, result: XPathResult): Iterable<Node>
}
export const deai: DocumentEvaluateAllIterable = (
xpathExpression: string,
contextNode?: Node,
order?: boolean,
result?: XPathResult,
) => {
const xpathResult = de(
xpathExpression,
contextNode,
order ? XPathResult.ORDERED_NODE_ITERATOR_TYPE : XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
result,
)
return {
[Symbol.iterator]: () => ({
next: () => {
let node = null
do {
node = xpathResult.iterateNext()
return node
? ({ done: false, value: node } as { done: false, value: Node })
: ({ done: true } as { done: true, value: any })
} while (node)
},
}),
}
}
type DocumentEvaluateSingle = {
<T extends Node>(xpathExpression: string): T | null
<T extends Node>(xpathExpression: string, contextNode: Node): T | null
<T extends Node>(xpathExpression: string, contextNode: Node, result: XPathResult): T | null
}
export const des: DocumentEvaluateSingle = <T extends Node>(
xpathExpression: string, contextNode?: Node, result?: XPathResult,
) => de(
xpathExpression,
contextNode,
XPathResult.FIRST_ORDERED_NODE_TYPE,
result,
).singleNodeValue as (T | null)
/** 空函数 */
export const none = () => {
// Do nothing