import { playerAgent } from '@/components/video/player-agent' import { getComponentSettings } from '@/core/settings' import { registerAndGetData } from '@/plugins/data' import type { Options } from '.' import type { KeyBindingAction, KeyBindingActionContext, KeyEvent } from './bindings' import { getActiveElement, simulateClick } from '@/core/utils' import { showDanmakuStateTip, showPlayerTip } from '../../video/player/common/player-tip/player-tip' export const keyEventToPointer = (event: KeyEvent): PointerEventInit => { return { ...lodash.pick(event, 'ctrlKey', 'shiftKey', 'altKey', 'metaKey'), bubbles: true, cancelable: true, view: unsafeWindow, } } export const clickElement = (target: string | HTMLElement, context: KeyBindingActionContext) => { const { event } = context const eventParams = keyEventToPointer(event) if (typeof target === 'string') { const targetElement = dq(target) as HTMLElement if (!targetElement) { return false } simulateClick(targetElement, eventParams) } else { if (!target) { return false } simulateClick(target, eventParams) } return true } export const useClickElement = (target: string | HTMLElement) => (context: KeyBindingActionContext) => clickElement(target, context) export const changeVideoTime = (delta: number | (() => number)) => () => playerAgent.changeTime(typeof delta === 'number' ? delta : delta()) const JumpForwardLongPressDelay = 300 const JumpForwardLongPressRate = 3 /** 显示播放器中央提示框 */ export const showTip = showPlayerTip export const builtInActions: Record = { fullscreen: { displayName: '全屏', run: () => playerAgent.fullscreen(), }, webFullscreen: { displayName: '网页全屏', run: () => playerAgent.webFullscreen(), }, wideScreen: { displayName: '宽屏', run: () => playerAgent.widescreen(), }, volumeUp: { displayName: '增加音量', ignoreFocus: false, run: () => { const speedHint = playerAgent.query.playerArea .sync() ?.querySelector('.bpx-player-three-playrate-hint') if (speedHint?.checkVisibility()) { return null } const step = getComponentSettings('keymap').options.volumeStep const volume = playerAgent.changeVolume(step) if (lodash.isNil(volume)) { return volume } showTip(`${volume}%`, 'mdi-volume-high') return true }, }, volumeDown: { displayName: '降低音量', ignoreFocus: false, run: () => { const speedHint = playerAgent.query.playerArea .sync() ?.querySelector('.bpx-player-three-playrate-hint') if (speedHint?.checkVisibility()) { return null } const step = getComponentSettings('keymap').options.volumeStep const volume = playerAgent.changeVolume(-step) if (lodash.isNil(volume)) { return volume } if (volume === 0) { showTip('静音', 'mdi-volume-off') } else { showTip(`${volume}%`, 'mdi-volume-high') } return true }, }, mute: { displayName: '静音', run: () => { const result = playerAgent.toggleMute() if (lodash.isNil(result)) { return result } if (playerAgent.isMute()) { showTip('已静音', 'mdi-volume-off') } else { showTip('已取消静音', 'mdi-volume-high') } return true }, }, pictureInPicture: { displayName: '画中画', run: () => playerAgent.togglePip(), }, coin: { displayName: '投币', run: useClickElement( '.video-toolbar .coin, .tool-bar .coin-info, .video-toolbar-module .coin-box, .play-options-ul > li:nth-child(2), .video-toolbar-v1 .coin, .toolbar .coin, .video-toolbar-container .video-coin', ), }, favorite: { displayName: '收藏', run: useClickElement( '.video-toolbar .collect, .video-toolbar-module .fav-box, .play-options-ul > li:nth-child(3), .video-toolbar-v1 .collect, .video-toolbar-container .video-fav', ), }, pause: { displayName: '暂停/播放', run: () => playerAgent.togglePlay(), }, like: { displayName: '点赞', run: (() => { /** 长按 `L` 三连使用的记忆变量 */ let likeClick = true return (context: KeyBindingActionContext) => { const { event } = context const likeButton = dq( '.video-toolbar .like, .tool-bar .like-info, .video-toolbar-v1 .like, .toolbar .like, .video-toolbar-container .video-like', ) as HTMLSpanElement if (!likeButton) { return false } event.preventDefault() const fireMouseEvent = (name: string) => { const eventParams: MouseEventInit = keyEventToPointer(event) const mouseEvent = new MouseEvent(name, eventParams) likeButton.dispatchEvent(mouseEvent) } likeClick = true setTimeout(() => (likeClick = false), 200) fireMouseEvent('mousedown') event.onRelease(({ sourceEvent, cancelled }) => { sourceEvent?.preventDefault() fireMouseEvent('mouseup') if (!cancelled && likeClick) { fireMouseEvent('click') } }) return true } })(), }, danmaku: { displayName: '弹幕开关', run: () => { const state = playerAgent.toggleDanmaku() if (lodash.isNil(state)) { return state } showDanmakuStateTip(state) return true }, }, longJumpBackward: { displayName: '长倒退', run: () => playerAgent.changeTime(-getComponentSettings('keymap').options.longJumpSeconds), }, longJumpForward: { displayName: '长前进', run: () => playerAgent.changeTime(getComponentSettings('keymap').options.longJumpSeconds), }, jumpBackward: { displayName: '倒退', run: () => playerAgent.changeTime(-5), }, jumpForward: { displayName: '前进', run: (() => { let activeCode: string = null let timer: number = null let originalPlaybackRate: number = null const reset = () => { if (timer !== null) { clearTimeout(timer) } if (originalPlaybackRate !== null) { playerAgent.nativeApi?.setPlaybackRate(originalPlaybackRate) } activeCode = null timer = null originalPlaybackRate = null } return ({ event }: KeyBindingActionContext) => { if (activeCode !== null) { return true } if (!getComponentSettings('keymap').options.enableLongPressSpeed) { return playerAgent.changeTime(5) } activeCode = event.code event.onRelease(({ cancelled }) => { const isShortPress = timer !== null reset() if (!cancelled && isShortPress) { playerAgent.changeTime(5) } }) timer = window.setTimeout(() => { timer = null const api = playerAgent.nativeApi if (!api?.getPlaybackRate || !api?.setPlaybackRate) { return } originalPlaybackRate = api.getPlaybackRate() if (api.isPaused?.()) { api.play() } api.setPlaybackRate(JumpForwardLongPressRate) }, JumpForwardLongPressDelay) return true } })(), }, previousPart: { displayName: '上一 P', run: () => playerAgent.previousPart(), }, nextPart: { displayName: '下一 P', run: () => playerAgent.nextPart(), }, focusDanmakuInput: { displayName: '聚焦弹幕输入框', run: () => { const input = playerAgent.query.bilibiliPlayer .sync() ?.querySelector('.bpx-player-dm-input') input?.focus() return input }, }, playerMenu: { displayName: '播放器菜单', run: () => { // menu size: 386.6 x 311 (2020-03-29) // menu size: 176.65 x 194 (2020-06-09) const player = playerAgent.query.video.container.sync() as HTMLElement if (!player) { return null } const rect = player.getBoundingClientRect() player.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true, cancelable: false, view: unsafeWindow, button: 2, buttons: 0, clientX: rect.x + rect.width / 2 - 176.65 / 2, clientY: rect.y + rect.height / 2 - 194 / 2, }), ) return true }, }, sendComment: { displayName: '发送评论', ignoreTyping: false, prevent: true, run: () => { const activeElement = getActiveElement() if (!activeElement) { return null } const isEditable = activeElement instanceof HTMLTextAreaElement || activeElement.hasAttribute('contenteditable') if (!isEditable) { return null } const getShadowRoot = (node: Node) => node.getRootNode() as ShadowRoot | null const sendButton = (() => { const candidates = [ () => activeElement.nextElementSibling, () => activeElement.parentElement.nextElementSibling, () => getShadowRoot(getShadowRoot(activeElement)?.host)?.querySelector('#pub button'), () => dq('.reply-box:focus-within .reply-box-send'), ] const match = candidates.find(fn => fn() !== null) if (match) { return match() as HTMLElement } return null })() if (!sendButton) { return null } sendButton.click() return true }, }, seekBegin: { displayName: '回开头', run: () => playerAgent.seek(0), }, /** 10% - 90% 的跳转动作 */ ...lodash.fromPairs( lodash.range(1, 10).map(i => [ `seekProgress${i}0`, { displayName: `跳转到 ${i}0%`, run: () => { const duration = (playerAgent.query.video.element.sync() as HTMLVideoElement)?.duration return duration ? (playerAgent.seek(duration * i * 0.1), true) : null }, } as KeyBindingAction, ]), ), } export const [actions] = registerAndGetData('keymap.actions', builtInActions)