import { VideoSpeedController } from '../video-speed/video-speed-controller' import { KeyBinding, KeyBindingAction, KeyBindingActionContext } from './key-bindings' const supportedUrls = [ 'https://www.bilibili.com/bangumi/', 'https://www.bilibili.com/video/', 'https://www.bilibili.com/cheese/', 'https://www.bilibili.com/watchlater/', 'https://www.bilibili.com/medialist/play/', 'https://www.bilibili.com/festival/2021bnj', ] let config: { enable: boolean, bindings: KeyBinding[] } | undefined = undefined if (supportedUrls.some(url => document.URL.startsWith(url))) { const clickElement = (target: string | HTMLElement) => { return ({ event }: KeyBindingActionContext) => { const mouseEvent = new MouseEvent('click', { ..._.pick(event, 'ctrlKey', 'shiftKey', 'altKey', 'metaKey') }) if (typeof target === 'string') { (dq(target) as HTMLElement)?.dispatchEvent(mouseEvent) } else { target.dispatchEvent(mouseEvent) } } } const changeVideoTime = (delta: number) => { return () => { const video = dq('.bilibili-player-video video') as HTMLVideoElement if (!video || !unsafeWindow.player) { return } unsafeWindow.player.seek(video.currentTime + delta, video.paused) } } /** 提示框用的`setTimeout`句柄 */ let tipTimeoutHandle: number /** * 显示提示框 * @param text 文字 (可以 HTML) * @param icon MDI 图标 class */ const showTip = (text: string, icon: string) => { let tip = dq('.keymap-tip') as HTMLDivElement if (!tip) { const player = dq('.bilibili-player-video-wrap') if (!player) { return } player.insertAdjacentHTML('afterbegin', /*html*/`
${text}
`) tip = dq('.keymap-tip') as HTMLDivElement } tip.innerHTML = text const container = dq('.keymap-tip-container') as HTMLDivElement const iconElement = dq(container, '.mdi') as HTMLElement iconElement.classList.remove(...iconElement.classList.values()) iconElement.classList.add('mdi', icon) if (tipTimeoutHandle) { clearTimeout(tipTimeoutHandle) } container.classList.add('show') tipTimeoutHandle = window.setTimeout(() => { container.classList.remove('show') }, 2000) } const videoSpeed = (controllerAction: (controller: VideoSpeedController, rates: number[]) => void) => { return async () => { const { VideoSpeedController } = await import('../video-speed/video-speed-controller') const controller = await VideoSpeedController.getInstance() controllerAction(controller, VideoSpeedController.supportedRates) showTip(`${controller.playbackRate}x`, 'mdi-fast-forward') } } const actions = { fullscreen: clickElement('.bilibili-player-video-btn-fullscreen'), webFullscreen: clickElement('.bilibili-player-video-web-fullscreen'), wideScreen: clickElement('.bilibili-player-video-btn-widescreen'), volumeUp: () => { const current = unsafeWindow.player.volume() unsafeWindow.player.volume(current + 0.1) showTip(`${Math.round(unsafeWindow.player.volume() * 100)}%`, 'mdi-volume-high') }, volumeDown: () => { const current = unsafeWindow.player.volume() unsafeWindow.player.volume(current - 0.1) const after = Math.round(unsafeWindow.player.volume() * 100) if (after === 0) { showTip('静音', 'mdi-volume-off') } else { showTip(`${after}%`, 'mdi-volume-high') } }, mute: (context: KeyBindingActionContext) => { clickElement('.bilibili-player-video-btn-volume .bilibili-player-iconfont-volume')(context) const isMute = unsafeWindow.player.isMute() if (isMute) { showTip('已静音', 'mdi-volume-off') } else { showTip('已取消静音', 'mdi-volume-high') } }, pictureInPicture: clickElement('.bilibili-player-video-btn-pip'), coin: clickElement('.video-toolbar .coin,.tool-bar .coin-info, .video-toolbar-module .coin-box, .play-options-ul > li:nth-child(2)'), favorite: clickElement('.video-toolbar .collect, .video-toolbar-module .fav-box, .play-options-ul > li:nth-child(3)'), pause: clickElement('.bilibili-player-video-btn-start'), like: (() => { /** 长按`L`三连使用的记忆变量 */ let likeClick = true return (({ isWatchlater, isMediaList, event }) => { if (isMediaList || isWatchlater) { const likeButton = dq('.play-options-ul > li:first-child') as HTMLLIElement if (likeButton) { likeButton.click() } } else { const likeButton = dq('.video-toolbar .like, .tool-bar .like-info') as HTMLSpanElement event.preventDefault() const fireEvent = (name: string, args: Event) => { const event = new CustomEvent(name, args) likeButton.dispatchEvent(event) } likeClick = true setTimeout(() => likeClick = false, 200) fireEvent('mousedown', event) document.body.addEventListener('keyup', e => { e.preventDefault() fireEvent('mouseup', e) if (likeClick) { fireEvent('click', e) } }, { once: true }) } }) as KeyBindingAction })(), danmaku: () => { const checkbox = dq('.bilibili-player-video-danmaku-switch input') as HTMLInputElement if (!checkbox) { return } checkbox.checked = !checkbox.checked raiseEvent(checkbox, 'change') }, longJumpBackward: changeVideoTime(-settings.keymapJumpSeconds), longJumpForward: changeVideoTime(settings.keymapJumpSeconds), jumpBackward: changeVideoTime(-5), jumpForward: changeVideoTime(5), playerMenu: () => { // menu size: 386.6 x 311 (2020-03-29) // menu size: 176.65 x 194 (2020-06-09) const player = dq('.bilibili-player-video-wrap') as HTMLElement if (!player) { return } 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 })) }, watchlater: clickElement('.video-toolbar .ops .watchlater, .more-ops-list .ops-watch-later, .video-toolbar-module .see-later-box'), quickFavorite: clickElement('.quick-favorite'), videoSpeedIncrease: videoSpeed((controller, rates) => { controller.setVideoSpeed(rates.find(it => it > controller.playbackRate) || rates[rates.length - 1]) }), videoSpeedDecrease: videoSpeed((controller, rates) => { controller.setVideoSpeed([...rates].reverse().find(it => it < controller.playbackRate) || rates[0]) }), videoSpeedReset: videoSpeed((controller) => { controller.toggleVideoSpeed() }), videoSpeedForget: videoSpeed((controller) => { controller.reset(true) }), takeScreenshot: clickElement('.video-take-screenshot'), previousFrame: clickElement('.prev-frame'), nextFrame: clickElement('.next-frame'), seekBegin: () => { if (!unsafeWindow.player) { return } unsafeWindow.player.play() setTimeout(() => { unsafeWindow.player.seek(0) const toastText = dq(".bilibili-player-video-toast-bottom .bilibili-player-video-toast-item:first-child .bilibili-player-video-toast-item-text span:nth-child(2)") if (toastText) { toastText.textContent = " 00:00" } }) }, } const defaultBindings: { [action in keyof typeof actions]: string } = { fullscreen: 'f', webFullscreen: 'w', wideScreen: 't', volumeUp: 'arrowUp', volumeDown: 'arrowDown', mute: 'm', pictureInPicture: 'p', coin: 'c', favorite: 's', pause: 'space', like: 'l', playerMenu: '`', longJumpForward: 'j', longJumpBackward: 'shift j', jumpBackward: 'arrowLeft', jumpForward: 'arrowRight', watchlater: 'shift w', quickFavorite: 'shift s', danmaku: 'd', videoSpeedIncrease: 'shift > 》 arrowUp', videoSpeedDecrease: 'shift < 《 arrowDown', videoSpeedReset: 'shift ? ?', videoSpeedForget: 'shift : :', takeScreenshot: 'ctrl [shift] alt c', previousFrame: 'shift arrowLeft', nextFrame: 'shift arrowRight', seekBegin: '0', } const parseBindings = (bindings: { [action: string]: string }) => { return Object.entries(bindings).map(([actionName, keyString]) => { const keys = keyString.split(' ') return { keys, action: (actions as any)[actionName] || (() => {}), } as KeyBinding }) } ;(async () => { const { loadKeyBindings } = await import('./key-bindings') const { presets } = await import('./key-binding-presets') addSettingsListener('keymapPreset', () => { const preset = presets[settings.keymapPreset] || {} const bindings = parseBindings( { ...defaultBindings, ...preset, ...settings.customKeyBindings } ) if (config) { console.log('load preset', settings.keymapPreset) config.bindings = bindings } else { config = loadKeyBindings(bindings) } }, true) resources.applyImportantStyle('keymapStyle') })() } export default { reload: () => config && (config.enable = true), unload: () => config && (config.enable = false), }