import { select } from '@/core/spin-query' import { raiseEvent } from '@/core/utils' import { ElementQuery, CustomNestedQuery, AgentType, PlayerQuery, CustomQuery, CustomQueryProvider, PlayerAgentEventTypes, PlayerAgentToggleSubtitleResult, } from './types' export const elementQuery = (selector: string): ElementQuery => { const func = () => select(selector) func.selector = selector func.sync = () => dq(selector) as HTMLElement return func } export const selectorWrap = (query: CustomNestedQuery): CustomNestedQuery => { const map = (value: string | Record): ElementQuery | Record => { if (typeof value !== 'string') { return lodash.mapValues(value, map) } return elementQuery(value) } return lodash.mapValues(query, map) as CustomNestedQuery } export const click = (target: ElementQuery) => { const button = target.sync() button?.click() return button } export abstract class PlayerAgent extends EventTarget implements EnumEventTarget<`${PlayerAgentEventTypes}`> { isBpxPlayer = true abstract type: AgentType abstract query: PlayerQuery constructor() { super() } provideCustomQuery>( config: CustomQueryProvider, ) { const custom = selectorWrap(config[this.type] ?? config.video) as CustomQuery return { ...this, custom, } as this & { custom: { [key in keyof CustomQueryType]: ElementQuery } } } widescreen() { return click(this.query.control.buttons.widescreen) } webFullscreen() { return click(this.query.control.buttons.webFullscreen) } fullscreen() { return click(this.query.control.buttons.fullscreen) } togglePlay() { return click(this.query.control.buttons.start) } togglePip() { return click(this.query.control.buttons.pip) } toggleMute() { return click(this.query.control.buttons.volume) } toggleDanmaku() { const checkbox = this.query.danmakuSwitch.sync() as HTMLInputElement if (!checkbox) { return null } checkbox.checked = !checkbox.checked raiseEvent(checkbox, 'change') return checkbox.checked } toggleSubtitle(): PlayerAgentToggleSubtitleResult { const closeSwitch = dq('.bpx-player-ctrl-subtitle-close-switch') as HTMLDivElement | null const isNoSubtitleConfigured = !closeSwitch if (isNoSubtitleConfigured) { return { element: null, result: 'no-subtitle-configured', } } const isSubtitleDisabled = closeSwitch?.classList.contains('bpx-state-active') if (!isSubtitleDisabled) { closeSwitch?.click() return { element: closeSwitch, result: 'success', } } const subtitleOptions = dqa( '.bpx-player-ctrl-subtitle-major .bpx-player-ctrl-subtitle-language-item', ) as HTMLDivElement[] if ((subtitleOptions?.length ?? 0) === 0) { return { element: null, result: 'no-subtitle-configured', } } const subtitleLanguage = this.getPlayerConfig('subtitle.lan', null) if (subtitleLanguage === null) { const firstOption = subtitleOptions.at(0) firstOption?.click() return { element: firstOption, result: 'success', } } // 优先选择用过的选项,其次选择与用过的选项相近的选项,最后考虑 AI 生成选项,都不满足则尝试选择可选项第一个 const matchers = [ () => subtitleOptions.find(it => it.dataset.lan === subtitleLanguage), () => subtitleOptions.find( it => !it.dataset.lan?.startsWith('ai-') && it.dataset.lan?.includes(subtitleLanguage.split('-')[0]), ), () => subtitleOptions.find(it => it.dataset.lan === `ai-${subtitleLanguage.split('-')[0]}`), () => subtitleOptions.at(0), ] const option = matchers.map(match => match()).find(Boolean) if (!option) { return { element: null, result: 'no-subtitle-configured', } } option.click() return { element: option, result: 'success', } } /** true 开灯,false 关灯 */ async toggleLight(on?: boolean) { if (!this.nativeApi) { return null } const isCurrentLightOff = this.nativeApi.getLightOff() // 无指定参数, 直接 toggle if (on === undefined) { this.nativeApi.setLightOff(!isCurrentLightOff) return !isCurrentLightOff } // 关灯状态 && 要开灯 -> 开灯 if (on && isCurrentLightOff) { this.nativeApi.setLightOff(false) return true } if (!on && !isCurrentLightOff) { this.nativeApi.setLightOff(true) return false } return null } getPlayerConfig( target: string, defaultValue?: DefaultValueType, ): ValueType | DefaultValueType { const storageKey = this.isBpxPlayer ? 'bpx_player_profile' : 'bilibili_player_settings' return lodash.get(JSON.parse(localStorage.getItem(storageKey)), target, defaultValue) } isAutoPlay() { return this.getPlayerConfig('video_status.autoplay') } // https://github.com/the1812/Bilibili-Evolved/discussions/4341 get nativeApi() { return unsafeWindow.player || unsafeWindow.playerRaw } get nanoApi() { return unsafeWindow.nano } get nanoTypeMap(): Record { return { [PlayerAgentEventTypes.Play]: this.nanoApi.EventType.Player_Play, [PlayerAgentEventTypes.Pause]: this.nanoApi.EventType.Player_Pause, } } private eventHandlerMap = new Map< EventListenerOrEventListenerObject, { handler: EventListener options: boolean | AddEventListenerOptions } >() addEventListener( type: `${PlayerAgentEventTypes}`, callback: EventListener, options?: boolean | AddEventListenerOptions, ): void { super.addEventListener(type, callback, options) const registerHandler = (nanoType: string) => { if (typeof options === 'object' && options.once) { this.nativeApi.once(nanoType, callback) } else { this.nativeApi.on(nanoType, callback) } this.eventHandlerMap.set(callback, { handler: callback, options, }) } const nanoType = this.nanoTypeMap[type] if (!nanoType) { console.warn('[PlayerAgent] unknown event type', type) return } registerHandler(nanoType) } removeEventListener( type: `${PlayerAgentEventTypes}`, callback: EventListener, options?: boolean | EventListenerOptions, ): void { super.removeEventListener(type, callback, options) const unregisterHandler = (nanoType: string) => { const handlerData = this.eventHandlerMap.get(callback) if (!handlerData || lodash.isEqual(options, handlerData.options)) { return } this.nativeApi.off(nanoType, handlerData.handler) } const nanoType = this.nanoTypeMap[type] if (!nanoType) { return } unregisterHandler(nanoType) } /** 获取是否静音 */ isMute() { if (!this.nativeApi) { return null } if (this.nativeApi.isMuted) { return this.nativeApi.isMuted() } return this.nativeApi.isMute() } /** 更改音量 (%) */ changeVolume(change: number) { if (!this.nativeApi) { return null } if (this.nativeApi.getVolume) { const current = this.nativeApi.getVolume() this.nativeApi.setVolume(current + change / 100) return Math.round(this.nativeApi.getVolume() * 100) } const current = this.nativeApi.volume() this.nativeApi.volume(current + change / 100) return Math.round(this.nativeApi.volume() * 100) } /** 跳转到指定时间 */ seek(time: number) { if (!this.nativeApi) { return null } this.nativeApi.seek(time) return this.nativeApi.getCurrentTime() as number } /** 更改时间 */ changeTime(change: number) { if (!this.nativeApi) { return null } const video = this.query.video.element.sync() as HTMLVideoElement if (!video) { return null } this.nativeApi.seek(video.currentTime + change, video.paused) return this.nativeApi.getCurrentTime() } }