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, Subject, 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 { EntryContext, 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, splitToSpace, 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 type Options = { /** 最大菜单高度 */ maxMenuHeight: boolean /** 隐藏进度条 */ hideScrollbar: boolean /** 隐藏移除图标 */ hideRemoveBtn: boolean /** 隐藏新增图标 */ hideAddBtn: boolean /** 扩展倍速列表 */ extendSpeedList: number[] } /** * 对于源订阅对象发出的每一对前后数组,计算两个数组的差异信息,生成一个 splice 参数形式的补丁数据提交到下一个订阅对象 * * 该算法来自 Neil Fraser 的 paper,只实现了最简单的预处理部分,即去除两个数组公共的前缀和后缀,然后计算差异部分 * 这种优化力度在当前「扩展倍速」组件的实现中已经完全足够了。除非设置介于中间值的扩展倍速项,否则生成的补丁应该是最优的 * * @see https://neil.fraser.name/writing/diff/ * */ const diff = (previousArr: number[], currentArr: number[]): [number, number, number[]] => { let pStartIdx = 0 let pEndIdx = previousArr.length let cStartIdx = 0 let cEndIdx = currentArr.length while ( pStartIdx < pEndIdx && cStartIdx < cEndIdx && previousArr[pStartIdx] === currentArr[cStartIdx] ) { pStartIdx++ cStartIdx++ } while ( pStartIdx < pEndIdx && cStartIdx < cEndIdx && previousArr[pEndIdx - 1] === currentArr[cEndIdx - 1] ) { pEndIdx-- cEndIdx-- } return [pStartIdx, pEndIdx - pStartIdx, currentArr.slice(cStartIdx, cEndIdx)] } interface VNode { tag?: T node: N destroy(): void } const $ = , R extends Element = Element>( html: string, scoped = true, ) => { const containerElement = document.createElement('div') containerElement.innerHTML = html const result = {} const root = containerElement.children.item(0) const walkChildren = (element: Element) => { if (scoped) { element.id = `scoped-element-${Math.random() .toString(36) .replace(/[^a-z0-9]+/g, '')}` } const dataRef = element.getAttribute('data-ref') if (dataRef) { result[lodash.camelCase(dataRef)] = element } for (let i = 0; i < element.children.length; i++) { walkChildren(element.children.item(i)) } } walkChildren(root) return { ...result, root } as E & { root: R; [index: string]: Element } } export class ExtendSpeedComponent extends EntrySpeedComponent { protected static get activeClassName() { return trimLeadingDot(PLAYER_AGENT.custom.active.selector) } protected static get showClassName() { return trimLeadingDot(PLAYER_AGENT.custom.show.selector) } protected static get speedMenuItemClassName() { return trimLeadingDot(PLAYER_AGENT.custom.speedMenuItem.selector) } constructor(entryContext: EntryContext, enabled$: Subject) { super(entryContext, enabled$) this.enabled$.subscribe( loadStyle({ name: 'fix-after-element', style: enabled => enabled && '.bpx-player-ctrl-playbackrate-menu:after { display: none; }', }), ) } addSpeedValue(value: number) { this.options.extendSpeedList = lodash.sortedUniq( this.options.extendSpeedList.concat(value).sort(ascendingSort()), ) } removeSpeedValue(value: number) { this.options.extendSpeedList = lodash.without(this.options.extendSpeedList, value) } createInputElement(): VNode { const { input, root, icon } = $<{ input: HTMLInputElement; icon: HTMLElement }, HTMLLIElement>(`
  • `) const updateRecommendedValue = () => { const value = this.speedContext.getAvailableSpeedValues().slice(-1)[0] + STEP_SPEED_VALUE const recommendedValue = lodash.toString(value > MAX_BROWSER_SPEED_VALUE ? null : value) input.value = recommendedValue input.min = recommendedValue } this.options.extendSpeedList$.pipe(observeOn(asapScheduler)).subscribe(updateRecommendedValue) const styleElement = addStyle(` #${input.id} { font-size: inherit; color: inherit; line-height: inherit; background: transparent; outline: none; width: 100%; border: none; text-align: center; cursor: text; } /* https://stackoverflow.com/a/4298216 */ /* Chrome */ #${input.id}::-webkit-outer-spin-button, #${input.id}::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } /* Firefox */ #${input.id}[type=number] { -moz-appearance:textfield; } `) const destroy = lodash.over( fromEvent(input, 'keydown') .pipe(debounceTime(200)) .subscribe(({ key }) => { if (key !== 'Enter') { return } const value = parseFloat(input.value) try { if (!lodash.isFinite(value)) { throw new Error('无效的倍数值') } if (value < MIN_BROWSER_SPEED_VALUE) { throw new Error('倍数值太小了') } if (value > MAX_BROWSER_SPEED_VALUE) { throw new Error('倍数值太大了') } if (this.speedContext.getAvailableSpeedValues().includes(value)) { throw new Error('不能重复添加已有的倍数值') } this.addSpeedValue(value) } catch (error) { logError(String(error), ERROR_MESSAGE_DURATION) input.focus() input.select() } }), fromEvent(root, 'mouseenter').subscribe(() => { input.style.display = 'inline' icon.style.display = 'none' updateRecommendedValue() setTimeout(() => input.focus()) }), fromEvent(root, 'mouseleave').subscribe(() => { input.style.display = 'none' icon.style.display = 'inline' }), () => root.remove(), () => styleElement.remove(), ) return { node: root, destroy, } } createCustomSpeedMenuItemElement(value: number): VNode { // 3.X的倍速需要通过data-value读取值并设置 const { closeBtn, root } = $<{ closeBtn: HTMLElement }, HTMLLIElement>(`
  • ${formatSpeedText(value)}
  • `) const styleElement = addStyle(` .${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"] { color: inherit; opacity: 0.5; display: none; position: absolute; right: 4px; } :is(${PLAYER_AGENT.custom.speedMenuItem.selector}):not(${PLAYER_AGENT.custom.active.selector}):hover [data-ref="close-btn"] { display: inline; } .${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"]:hover { opacity: 1; transition: all .3s; } `) const destroy = lodash.over( fromEvent(closeBtn, 'click').subscribe(() => { this.removeSpeedValue(value) }), () => root.remove(), () => styleElement.remove(), ) return { tag: value, node: root, destroy, } } protected elementMap: VNode[] = [] protected inputElement: VNode protected unpatch: TeardownLogic protected migrate() { type MixedRememberSpeedOptions = Partial & { extendList?: number[] extend?: unknown } const { options } = this.settings const { options: rememberSpeedOptions } = getComponentSettings( 'rememberVideoSpeed', ) as ComponentSettings if (rememberSpeedOptions.extendList) { options.extendSpeedList = Array.from(rememberSpeedOptions.extendList as number[]) delete rememberSpeedOptions.extendList delete rememberSpeedOptions.extend Toast.success('从「倍速记忆」组件迁移旧配置成功', '【扩展倍速】旧配置迁移完成', 8e3) } } // eslint-disable-next-line class-methods-use-this getSpeedContextMixin({ menuListElement }: SpeedContext): Partial { return { query: (speed: number) => des( // 有的时候,选择 1.0x 倍速,会错误选中 11.0x 倍速 // 删掉 11.0x 倍速后,再选择 1.0x 倍速就没出现该问题,这是因为之前的实现使用 contains 函数判断,由于 11.0x 倍速文本包含 1.0x,所以被误判了 // 原先使用 contains 而不直接比较的原因是,自定义的倍速菜单项,可能在创建时引入了多余的空白符,现在使用 normalize-space 代替之前的做法 // `./*[contains(@class, "${ExtendSpeedComponent.speedMenuItemClassName}")` `./*[(${ExtendSpeedComponent.speedMenuItemClassName .split(',') .map(cls => `contains(@class, "${cls}")`) .join(' or ')})` + ` 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$, playbackRate$ }: 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 !important; /* 防止3.X样式覆盖 */ flex-direction: column; overflow-y: auto; max-height: ${maxMenuHeight}px; visibility: hidden; } /* 修复2.X倍速列表显示问题 */ :is(${PLAYER_AGENT.custom.show.selector}) :is(${PLAYER_AGENT.custom.speedMenuList.selector}){ visibility: visible; } /* 修复番剧区的列表显示问题 */ :is(${PLAYER_AGENT.custom.speedMenuList.selector})[style*="block"] { visibility: visible; }`, }), ) 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; } :is(${PLAYER_AGENT.custom.speedMenuList.selector})::-webkit-scrollbar { height: 0 !important; width: 0 !important; }`, }), ) this.options.hideRemoveBtn$.subscribe( loadStyle({ name: 'extend-video-speed-no-remove-btn-style', style: hideRemoveBtn => hideRemoveBtn && ` .${EXTEND_SPEED_ITEM_CLASS_NAME} [data-ref="close-btn"] { display: none !important; } :is(${PLAYER_AGENT.custom.speedMenuItem.selector}):not(${PLAYER_AGENT.custom.active.selector}):hover [data-ref="close-btn"] { display: none !important; }`, }), ) this.options.hideAddBtn$.subscribe( loadStyle({ name: 'extend-video-speed-no-add-btn-style', style: hideAddBtn => hideAddBtn && ` .${EXTEND_SPEED_INPUT_CLASS_NAME} { display: none !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) }, }) this.currentSpeedValue && requestIdleCallback(() => { this.setVideoSpeed(this.currentSpeedValue, 1000) }) } protected async forceVideoSpeedWithUpdateStyle(value: number) { await this.forceVideoSpeed(value) // 番剧类的视频的激活 class 居然是按子元素顺序修改的,只能步其后尘再强制改一次了 setTimeout(() => this.forceUpdateStyle(value)) } // eslint-disable-next-line class-methods-use-this protected readonly filterNativeSpeed = () => ({ subscribe, next }: PublishContext) => { subscribe(currentSpeed => { if (NATIVE_SUPPORTED_VALUES.includes(currentSpeed)) { next(currentSpeed) } }) } protected patch(params: [number, number, number[]]) { const [start, deleteCount, added] = params const { menuListElement } = this.speedContext if (!this.inputElement) { this.inputElement = this.createInputElement() menuListElement.prepend(this.inputElement.node) } if (deleteCount === 0 && added.length === 0) { return } const addedElements = added.map(v => this.createCustomSpeedMenuItemElement(v)) const deleted = this.elementMap.splice(start, deleteCount, ...addedElements) deleted.forEach(vnode => { vnode.destroy() }) ;(this.elementMap[start - 1] || this.inputElement).node.after( ...addedElements.map(el => el.node).reverse(), ) // 为所有倍速菜单项刷新 Order menuListElement .querySelectorAll( `:is(${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( `./*[(${trimLeadingDot(ExtendSpeedComponent.speedMenuItemClassName) .split(',') .map(cls => `contains(@class, "${cls}")`) .join(' or ')})` + ' and ' + `(${trimLeadingDot(ExtendSpeedComponent.activeClassName) .split(',') .map(cls => `contains(@class, "${cls}")`) .join(' or ')})]`, menuListElement, ) as Iterable) { element.classList.remove(...ExtendSpeedComponent.activeClassName.split(',')) } // 对于被强制更新的菜单项,添加激活态的类名 querySpeedMenuItemElement(value).classList.add( ...ExtendSpeedComponent.activeClassName.split(','), ) // 关闭菜单 containerElement.classList.remove(...ExtendSpeedComponent.showClassName.split(',')) // 更新倍速菜单按钮文本 nameBtnElement.innerText = formatSpeedText(value, true) } }