ZenzaWatchをゲームパッドで操作
// ==UserScript== // @name ZenzaGamePad // @namespace https://github.com/segabito/ // @description ZenzaWatchをゲームパッドで操作 // @include *://*.nicovideo.jp/* // @version 1.5.3 // @author segabito macmoto // @license public domain // @grant none // @noframes // ==/UserScript== /* eslint-disable */ // 推奨 // // XInput系 (XboxOne, Xbox360コントローラ等) // DualShock4 // USBサターンパッド // 8bitdo FC30系 // Joy-Con L R (async (window) => { const monkey = (ZenzaWatch) => { if (!window.navigator.getGamepads) { window.console.log('%cGamepad APIがサポートされていません', 'background: red; color: yellow;'); return; } const PRODUCT = 'ZenzaGamePad'; const CONSTANT = { BASE_Z_INDEX: 150000 }; const ButtonMapJoyConL = { Y: 0, B: 1, X: 2, A: 3, SUP: 4, SDN: 5, SEL: 8, CAP: 13, LR: 14, META: 15, PUSH: 10 }; const ButtonMapJoyConR = { Y: 3, B: 2, X: 1, A: 0, SUP: 5, SDN: 4, SEL: 9, CAP: 12, LR: 14, META: 15, PUSH: 11 }; const JoyConAxisCenter = +1.28571; const AxisMapJoyConL = { CENTER: JoyConAxisCenter, UP: +0.71429, U_R: +1.00000, RIGHT: -1.00000, D_R: -0.71429, DOWN: -0.42857, D_L: -0.14286, LEFT: +0.14286, U_L: +0.42857, }; const AxisMapJoyConR = { CENTER: JoyConAxisCenter, UP: -0.42857, U_R: -0.14286, RIGHT: +0.14286, D_R: +0.42857, DOWN: +0.71429, D_L: +1.00000, LEFT: -1.00000, U_L: -0.71429, }; let _ = window._ || ZenzaWatch.lib._; let $ = window.jQuery || ZenzaWatch.lib.$; let util = ZenzaWatch.util; let Emitter = ZenzaWatch.modules ? ZenzaWatch.modules.Emitter : ZenzaWatch.lib.AsyncEmitter; let isZenzaWatchOpen = false; let console; let debugMode = !true; let dummyConsole = { log: _.noop, error: _.noop, time: _.noop, timeEnd: _.noop, trace: _.noop }; console = debugMode ? window.console : dummyConsole; let isPauseButtonDown = false; let isRate1ButtonDown = false; let isMetaButtonDown = false; const getVideo = () => { return document.querySelector('.zenzaWatchVideoElement'); }; const video = { get duration() { return getVideo().duration; } }; const Config = (() => { const prefix = PRODUCT + '_config_'; const emitter = new Emitter(); const defaultConfig = { debug: false, enabled: true, needFocus: false, deviceIndex: 0 }; const config = {}; emitter.refresh = (emitChange = false) => { Object.keys(defaultConfig).forEach(key => { const storageKey = prefix + key; if (localStorage.hasOwnProperty(storageKey)) { try { let lastValue = config[key]; let newValue = JSON.parse(localStorage.getItem(storageKey)); if (lastValue !== newValue) { config[key] = newValue; if (emitChange) { emitter.emit('key', newValue); emitter.emit('@update', {key, value: newValue}); } } } catch (e) { window.console.error('config parse error key:"%s" value:"%s" ', key, localStorage.getItem(storageKey), e); config[key] = defaultConfig[key]; } } else { config[key] = defaultConfig[key]; } }); }; emitter.refresh(); emitter.get = (key, refresh) => { if (refresh) { emitter.refreshValue(key); } return config[key]; }; emitter.set = (key, value = undefined) => { if (config[key] !== value && value !== undefined) { const storageKey = prefix + key; localStorage.setItem(storageKey, JSON.stringify(value)); config[key] = value; emitter.emit(key, value); emitter.emit('@update', {key, value}); } }; return emitter; })(); class BaseViewComponent extends Emitter { constructor({parentNode = null, name = '', template = '', shadow = '', css = ''}) { super(); this._params = {parentNode, name, template, shadow, css}; this._bound = {}; this._state = {}; this._props = {}; this._elm = {}; this._initDom({ parentNode, name, template, shadow, css }); } _initDom({parentNode, name, template, css = '', shadow = ''}) { let tplId = `${PRODUCT}${name}Template`; let tpl = document.getElementById(tplId); if (!tpl) { if (css) { util.addStyle(css, `${name}Style`); } tpl = document.createElement('template'); tpl.innerHTML = template; tpl.id = tplId; document.body.appendChild(tpl); } const onClick = this._bound.onClick = this._onClick.bind(this); const view = document.importNode(tpl.content, true); this._view = view.querySelector('*') || document.createDocumentFragment(); if (this._view) { this._view.addEventListener('click', onClick); } this.appendTo(parentNode); if (shadow) { this._attachShadow({host: this._view, name, shadow}); if (!this._isDummyShadow) { this._shadow.addEventListener('click', onClick); } } } _attachShadow ({host, shadow, name, mode = 'open'}) { let tplId = `${PRODUCT}${name}Shadow`; let tpl = document.getElementById(tplId); if (!tpl) { tpl = document.createElement('template'); tpl.innerHTML = shadow; tpl.id = tplId; document.body.appendChild(tpl); } if (!host.attachShadow && !host.createShadowRoot) { return this._fallbackNoneShadowDom({host, tpl, name}); } const root = host.attachShadow ? host.attachShadow({mode}) : host.createShadowRoot(); const node = document.importNode(tpl.content, true); root.appendChild(node); this._shadowRoot = root; this._shadow = root.querySelector('.root'); this._isDummyShadow = false; } _fallbackNoneShadowDom({host, tpl, name}) { const node = document.importNode(tpl.content, true); const style = node.querySelector('style'); style.remove(); util.addStyle(style.innerHTML, `${name}Shadow`); host.appendChild(node); this._shadow = this._shadowRoot = host.querySelector('.root'); this._isDummyShadow = true; } setState(key, val) { if (typeof key === 'string') { this._setState(key, val); } Object.keys(key).forEach(k => { this._setState(k, key[k]); }); } _setState(key, val) { if (this._state[key] !== val) { this._state[key] = val; if (/^is(.*)$/.test(key)) { this.toggleClass(`is-${RegExp.$1}`, !!val); } this.emit('update', {key, val}); } } _onClick(e) { const target = e.target.classList.contains('command') ? e.target : e.target.closest('.command'); if (!target) { return; } const command = target.getAttribute('data-command'); if (!command) { return; } const type = target.getAttribute('data-type') || 'string'; let param = target.getAttribute('data-param'); e.stopPropagation(); e.preventDefault(); param = this._parseParam(param, type); this._onCommand(command, param); } _parseParam(param, type) { switch (type) { case 'json': case 'bool': case 'number': param = JSON.parse(param); break; } return param; } appendTo(parentNode) { if (!parentNode) { return; } this._parentNode = parentNode; parentNode.appendChild(this._view); } _onCommand(command, param) { this.emit('command', command, param); } toggleClass(className, v) { (className || '').split(/ +/).forEach((c) => { if (this._view && this._view.classList) { this._view.classList.toggle(c, v); } if (this._shadow && this._shadow.classList) { this._shadow.classList.toggle(c, this._view.classList.contains(c)); } }); } addClass(name) { this.toggleClass(name, true); } removeClass(name) { this.toggleClass(name, false); } } class ConfigPanel extends BaseViewComponent { constructor({parentNode}) { super({ parentNode, name: 'ZenzaGamePadConfigPanel', shadow: ConfigPanel.__shadow__, template: '<div class="ZenzaGamePadConfigPanelContainer zen-family"></div>', css: '' }); this._state = { isOpen: false, isVisible: false }; Config.on('refresh', this._onBeforeShow.bind(this)); } _initDom(...args) { super._initDom(...args); const v = this._shadow; this._elm.enabled = v.querySelector('[data-config-name="enabled"]'); this._elm.needFocus = v.querySelector('[data-config-name="needFocus"]'); this._elm.deviceIndex = v.querySelector('[data-config-name="deviceIndex"]'); const onChange = e => { const target = e.target, name = target.getAttribute('data-config-name'); switch (target.tagName) { case 'SELECT': case 'INPUT': if (target.type === 'checkbox') { Config.set(name, target.checked); } else { const type = target.getAttribute('data-type'); const value = this._parseParam(target.value, type); Config.set(name, value); } break; default: //console.info('target', e, target, name, target.checked); Config.set(name, !!target.checked); break; } }; this._elm.enabled.addEventListener('change', onChange); this._elm.needFocus.addEventListener('change', onChange); this._elm.deviceIndex.addEventListener('change', onChange); v.querySelector('.closeButton') .addEventListener('click', this.hide.bind(this)); } _onClick(e) { super._onClick(e); } _onMouseDown(e) { this.hide(); this._onClick(e); } show() { document.body.addEventListener('click', this._bound.onBodyClick); this._onBeforeShow(); this.setState({isOpen: true}); if (this._shadow.showModal) { this._shadow.showModal(); } window.setTimeout(() => { this.setState({isVisible: true}); }, 100); } hide() { document.body.removeEventListener('click', this._bound.onBodyClick); if (this._shadow.close) { this._shadow.close(); } this.setState({isVisible: false}); window.setTimeout(() => { this.setState({isOpen: false}); }, 2100); } toggle() { if (this._state.isOpen) { this.hide(); } else { this.show(); } } _onBeforeShow() { this._elm.enabled.checked = !!Config.get('enabled'); this._elm.needFocus.checked = !!Config.get('needFocus'); this._elm.deviceIndex.value = Config.get('deviceIndex'); } } ConfigPanel.__shadow__ = (` <style> .ZenzaGamePadConfigPanel { display: none; position: fixed; z-index: ${CONSTANT.BASE_Z_INDEX}; top: 50vh; left: 50vw; padding: 8px; border: 2px outset; box-shadow: 0 0 8px #000; background: #ccc; transform: translate(-50%, -50%); transition: opacity 0.5s; transform-origin: center bottom; animation-timing-function: steps(10); perspective-origin: center bottom; user-select: none; margin: 0; pointer-events: auto !important; } .ZenzaGamePadConfigPanel[open] { display: block; opacity: 1; } .ZenzaGamePadConfigPanel.is-Open { display: block; opacity: 0; } .ZenzaGamePadConfigPanel.is-Open.is-Visible { opacity: 1; } .title { font-weight: bolder; font-size: 120%; font-family: 'arial black'; margin: 0 0 8px; text-align: center; } .closeButton { display: block; text-align: center; } .closeButton { display: block; padding: 8px 16px; cursor: pointer; margin: auto; } label { cursor: pointer; } input[type="number"] { width: 50px; } input[type="checkbox"] { transform: scale(2); margin-right: 16px; } .ZenzaGamePadConfigPanel>div { padding: 8px; } </style> <dialog class="root ZenzaGamePadConfigPanel zen-family"> <p class="title">†ZenzaGamePad†</p> <div class="enableSelect"> <label> <input type="checkbox" data-config-name="enabled" data-type="bool"> ZenzaGamePadを有効にする </label> </div> <div class="needFocusSelect"> <label> <input type="checkbox" data-config-name="needFocus" data-type="bool"> ウィンドウフォーカスのあるときのみ有効 </label> </div> <div class="deviceIndex"> <label> デバイス番号 <select class="deviceIndexSelector" data-config-name="deviceIndex" data-type="number"> <option value="0">0</option> <option value="1">1</option> <option value="2">2</option> <option value="3">3</option> </select> <small>(※リロードが必要)</small> </label> </div> <div class="closeButtonContainer"> <button class="closeButton" type="button"> 閉じる </button> </div> </dialog> `).trim(); class ToggleButton extends BaseViewComponent { constructor({parentNode}) { super({ parentNode, name: 'ZenzaGamePadToggleButton', shadow: ToggleButton.__shadow__, template: '<div class="ZenzaGamePadToggleButtonContainer"></div>', css: '' }); this._state = { isEnabled: undefined }; Config.on('enabled', () => { this.refresh(); }); } refresh() { this.setState({isEnabled: Config.get('enabled')}); } } ToggleButton.__shadow__ = ` <style> .controlButton { position: relative; display: inline-block; transition: opacity 0.4s ease, margin-left 0.2s ease, margin-top 0.2s ease; box-sizing: border-box; text-align: center; cursor: pointer; color: #fff; opacity: 0.8; vertical-align: middle; } .controlButton:hover { cursor: pointer; opacity: 1; } .controlButton .controlButtonInner { filter: grayscale(100%); } .heatSyncSwitch { font-size: 16px; width: 32px; height: 32px; line-height: 30px; cursor: pointer; } .is-Enabled .controlButtonInner { color: #aef; filter: none; } .controlButton .tooltip { display: none; pointer-events: none; position: absolute; left: 16px; top: -30px; transform: translate(-50%, 0); font-size: 12px; line-height: 16px; padding: 2px 4px; border: 1px solid !000; background: #ffc; color: #000; text-shadow: none; white-space: nowrap; z-index: 100; opacity: 0.8; } .controlButton:hover { background: #222; } .controlButton:hover .tooltip { display: block; opacity: 1; } </style> <div class="heatSyncSwitch controlButton root command" data-command="toggleZenzaGamePadConfig"> <div class="controlButtonInner" title="ZenzaGamePad">🎮</div> <div class="tooltip">ZenzaGamePad</div> </div> `.trim(); const execCommand = (command, param) => ZenzaWatch.external.execCommand(command, param); const speedUp = () => { // TODO: // configを直接参照するのはお行儀が悪いのでexternalのインターフェースをつける const current = parseFloat(ZenzaWatch.config.getValue('playbackRate'), 10); window.console.log('speedUp', current); execCommand('playbackRate', Math.floor(Math.min(current + 0.1, 3) * 10) / 10); }; const speedDown = () => { // TODO: // configを直接参照するのはお行儀が悪いのでexternalのインターフェースをつける const current = parseFloat(ZenzaWatch.config.getValue('playbackRate'), 10); window.console.log('speedDown', current); execCommand('playbackRate', Math.floor(Math.max(current - 0.1, 0.1) * 10) / 10); }; const scrollUp = () => { document.documentElement.scrollTop = Math.max(0, document.documentElement.scrollTop - window.innerHeight / 5); }; const scrollDown = () => { document.documentElement.scrollTop = document.documentElement.scrollTop + window.innerHeight / 5; }; const scrollToVideo = () => { getVideo().scrollIntoView({behavior: 'smooth', block: 'center'}); }; const swapABXY_FC30 = btn => { switch (btn) { case 0: return 1; case 1: return 0; case 3: return 4; case 4: return 3; } return btn; }; const onButtonDown = (button, deviceId) => { if (!isZenzaWatchOpen) { return; } if (deviceId.match(/Vendor: 04b4 Product: 010a/i)) { //USB Gamepad (Vendor: 04b4 Product: 010a)" return onButtonDownSaturn(button, deviceId); } else if (deviceId.match(/Vendor: (3810|05a0|1235|1002)/i)) { // FC30なのにみんなVendor違うってどういうことだよ // 8Bitdo FC30 Pro (Vendor: 1002 Product: 9000) return onButtonDownFC30(button, deviceId); } else if (deviceId.match(/Vendor: 057e Product: 200[67]/i)) { return onButtonDownJoyCon(button, deviceId); } switch (button) { case 0: // A isPauseButtonDown = true; execCommand('togglePlay'); break; case 1: // B execCommand('toggle-mute'); break; case 2: // X execCommand('toggle-showComment'); break; case 3: // Y isRate1ButtonDown = true; execCommand('playbackRate', 0.1); break; case 4: // LB execCommand('playPreviousVideo'); break; case 5: // RB execCommand('playNextVideo'); break; case 6: // LT execCommand('playbackRate', 0.5); break; case 7: // RT execCommand('playbackRate', 3); break; case 8: // しいたけの左 ビューボタン (Back) execCommand('screenShot'); break; case 9: // しいたけの右 メニューボタン (Start) execCommand('deflistAdd'); break; case 10: // Lスティック execCommand('seek', 0); break; case 11: // Rスティック execCommand('toggle-fullscreen'); break; case 12: // up if (isPauseButtonDown) { speedUp(); } else { execCommand('volumeUp'); } break; case 13: // down if (isPauseButtonDown) { speedDown(); } else { execCommand('volumeDown'); } break; case 14: // left if (isPauseButtonDown) { execCommand('seekPrevFrame'); } else { execCommand('seekBy', isRate1ButtonDown ? -1 : -5); } break; case 15: // right if (isPauseButtonDown) { execCommand('seekNextFrame'); } else { execCommand('seekBy', isRate1ButtonDown ? +1 : +5); } break; } }; const onButtonDownSaturn = (button, deviceId) => { switch (button) { case 0: // A isPauseButtonDown = true; execCommand('togglePlay'); break; case 1: // B execCommand('toggle-mute'); break; case 2: // C execCommand('toggle-showComment'); break; case 3: // X execCommand('playbackRate', 0.5); break; case 4: // Y isRate1ButtonDown = true; execCommand('playbackRate', 0.1); break; case 5: // Z execCommand('playbackRate', 3); break; case 6: // L execCommand('playPreviousVideo'); break; case 7: // R execCommand('playNextVideo'); break; case 8: // START execCommand('deflistAdd'); break; } }; const onButtonDownJoyCon = (button, deviceId) => { const ButtonMap = deviceId.match(/Vendor: 057e Product: 2006/i) ? ButtonMapJoyConL : ButtonMapJoyConR; switch (button) { case ButtonMap.Y: if (isPauseButtonDown) { execCommand('seekPrevFrame'); } else { execCommand('toggle-showComment'); } break; case ButtonMap.B: isPauseButtonDown = true; execCommand('togglePlay'); break; case ButtonMap.X: if (isMetaButtonDown) { execCommand('playbackRate', 2); } else { isRate1ButtonDown = true; execCommand('playbackRate', 0.1); } break; case ButtonMap.A: if (isPauseButtonDown) { execCommand('seekNextFrame'); } else { execCommand('toggle-mute'); } break; case ButtonMap.SUP: if (isMetaButtonDown) { scrollUp(); } else { execCommand('playPreviousVideo'); } break; case ButtonMap.SDN: if (isMetaButtonDown) { scrollDown(); } else { execCommand('playNextVideo'); } break; case ButtonMap.SEL: if (isMetaButtonDown) { execCommand('toggle-loop'); } else { execCommand('deflistAdd'); } break; case ButtonMap.CAP: execCommand('screenShot'); break; case ButtonMap.PUSH: if (isMetaButtonDown) { scrollToVideo(); } else { execCommand('seek', 0); } break; case ButtonMap.LR: execCommand('toggle-fullscreen'); break; case ButtonMap.META: isMetaButtonDown = true; break; } }; const onButtonDownFC30 = (button, deviceId) => { if (deviceId.match(/Product: (3232)/i)) { // FC30 Zero / FC30 button = swapABXY_FC30(button); } switch (button) { case 0: // B execCommand('toggle-mute'); break; case 1: // A isPauseButtonDown = true; execCommand('togglePlay'); break; case 2: // ??? //execCommand('toggle-showComment'); break; case 3: // X isRate1ButtonDown = true; execCommand('playbackRate', 0.1); break; case 4: // Y execCommand('toggle-showComment'); break; case 5: // break; case 6: // L1 if (isPauseButtonDown) { execCommand('playPreviousVideo'); } else { execCommand('playbackRate', 0.5); } break; case 7: // R1 if (isPauseButtonDown) { execCommand('playNextVideo'); } else { execCommand('playbackRate', 3); } break; case 8: // L2 execCommand('playPreviousVideo'); break; case 9: // R2 execCommand('playNextVideo'); break; case 10: // SELECT execCommand('screenShot'); break; case 11: // START execCommand('deflistAdd'); break; case 13: // Lスティック execCommand('seek', 0); break; case 14: // Rスティック break; } }; const onButtonUp = (button, deviceId) => { if (!isZenzaWatchOpen) { return; } if (deviceId.match(/Vendor: 04b4 Product: 010a/i)) { //USB Gamepad (Vendor: 04b4 Product: 010a)" return onButtonUpSaturn(button, deviceId); } else if (deviceId.match(/Vendor: (3810|05a0|1235|1002)/i)) { // 8Bitdo FC30 Pro (Vendor: 1002 Product: 9000) return onButtonUpFC30(button, deviceId); } else if (deviceId.match(/Vendor: 057e Product: 200[67]/i)) { return onButtonUpJoyCon(button, deviceId); } switch (button) { case 0: // A isPauseButtonDown = false; break; case 3: // Y isRate1ButtonDown = false; execCommand('playbackRate', 1.0); break; case 7: // RT execCommand('playbackRate', 1.5); break; } }; const onButtonUpSaturn = (button, deviceId) => { switch (button) { case 0: // A isPauseButtonDown = false; break; case 1: // B break; case 2: // C break; case 3: // X break; case 4: // Y isRate1ButtonDown = false; execCommand('playbackRate', 1.0); break; case 5: // Z execCommand('playbackRate', 1.5); break; case 6: // L break; case 7: // R break; case 8: // START break; } }; const onButtonUpFC30 = (button, deviceId) => { if (deviceId.match(/Product: (3232)/i)) { // FC30Zero / FC30 button = swapABXY_FC30(button); } switch (button) { case 0: // B break; case 1: // A isPauseButtonDown = false; break; case 2: // ??? break; case 3: // X isRate1ButtonDown = false; execCommand('playbackRate', 1.0); break; case 4: // Y break; case 5: // break; case 6: // L1 break; case 7: // R1 if (isPauseButtonDown) { return; } execCommand('playbackRate', 1.5); break; case 8: // L2 break; case 9: // R2 break; case 10: // SELECT break; case 11: // START break; case 13: // Lスティック break; case 14: // Rスティック break; } }; const onButtonUpJoyCon = (button, deviceId) => { const ButtonMap = deviceId.match(/Vendor: 057e Product: 2006/i) ? ButtonMapJoyConL : ButtonMapJoyConR; switch (button) { case ButtonMap.Y: break; case ButtonMap.B: isPauseButtonDown = false; break; case ButtonMap.X: isRate1ButtonDown = false; execCommand('playbackRate', 1); break; case ButtonMap.META: isMetaButtonDown = false; break; } }; const onButtonRepeat = (button, deviceId) => { if (!isZenzaWatchOpen) { return; } if (deviceId.match(/Vendor: 057e Product: 200[67]/i)) { // Joy-Con return onButtonRepeatJoyCon(button, deviceId); } switch (button) { case 12: // up if (isPauseButtonDown) { speedUp(); } else { execCommand('volumeUp'); } break; case 13: // down if (isPauseButtonDown) { speedUp(); } else { execCommand('volumeDown'); } break; case 14: // left if (isPauseButtonDown) { execCommand('seekPrevFrame'); } else { execCommand('seekBy', isRate1ButtonDown ? -1 : -5); } break; case 15: // right if (isPauseButtonDown) { execCommand('seekNextFrame'); } else { execCommand('seekBy', isRate1ButtonDown ? +1 : +5); } break; } }; const onButtonRepeatJoyCon = (button, deviceId) => { const ButtonMap = deviceId.match(/Vendor: 057e Product: 2006/i) ? ButtonMapJoyConL : ButtonMapJoyConR; switch (button) { case ButtonMap.Y: if (isMetaButtonDown) { execCommand('seekBy', -15); } else if (isPauseButtonDown) { execCommand('seekPrevFrame'); } break; case ButtonMap.A: if (isMetaButtonDown) { execCommand('seekBy', 15); } else if (isPauseButtonDown) { execCommand('seekNextFrame'); } break; case ButtonMap.SUP: if (isMetaButtonDown) { scrollUp(); } else { execCommand('playPreviousVideo'); } break; case ButtonMap.SDN: if (isMetaButtonDown) { scrollDown(); } else { execCommand('playNextVideo'); } break; } }; const onAxisChange = (axis, value, deviceId) => { if (!isZenzaWatchOpen) { return; } if (Math.abs(value) < 0.1) { return; } const isFC30 = deviceId.match(/Vendor: 3810/i) ? true : false; if (isFC30) { switch (axis) { case 3: // L2なぜか反応する case 4: // R2なぜか反応する return; case 5: // FC30のRスティック上下? axis = 3; break; } } if (deviceId.match(/Vendor: 057e Product: 200[67]/i)) { // Joy-Con return; } switch (axis) { case 0: {// Lスティック X const step = isRate1ButtonDown ? 1 : 5; execCommand('seekBy', (value < 0 ? -1 : 1) * step); } break; case 1: // Lスティック Y if (isPauseButtonDown) { if (value < 0) { speedUp(); } else { speedDown(); } } else { execCommand(value < 0 ? 'volumeUp' : 'volumeDown'); } break; case 2: // Rスティック X break; case 3: // Rスティック Y if (value < 0) { speedUp(); } else { speedDown(); } break; } }; const onAxisRepeat = (axis, value, deviceId) => { if (!isZenzaWatchOpen) { return; } if (Math.abs(value) < 0.1) { return; } if (deviceId.match(/Vendor: 057e Product: 2006/i)) { // Joy-Con (L) return; } else if (deviceId.match(/Vendor: 057e Product: 2007/i)) { // Joy-Con (R) return; } switch (axis) { case 0: {// Lスティック X const step = isRate1ButtonDown ? 1 : +5; execCommand('seekBy', (value < 0 ? -1 : 1) * step); } break; case 1: // Lスティック Y if (isPauseButtonDown) { if (value < 0) { speedUp(); } else { speedDown(); } } else { execCommand(value < 0 ? 'volumeUp' : 'volumeDown'); } break; case 2: // Rスティック X break; case 3: // Rスティック Y if (value < 0) { speedUp(); } else { speedDown(); } break; } }; const onPovChange = (pov, deviceId) => { switch(pov) { case 'UP': if (isMetaButtonDown) { speedUp(); } else { execCommand('volumeUp'); } break; case 'DOWN': if (isMetaButtonDown) { speedDown(); } else { execCommand('volumeDown'); } break; case 'LEFT': execCommand('seekBy', isRate1ButtonDown || isMetaButtonDown ? -1 : -5); break; case 'RIGHT': execCommand('seekBy', isRate1ButtonDown || isMetaButtonDown ? +1 : +5); break; } }; const onPovRepeat = onPovChange; class PollingTimer { constructor(callback, interval) { this._timer = null; this._callback = callback; if (typeof interval === 'number') { this.changeInterval(interval); } } changeInterval(interval) { if (this._timer) { if (this._currentInterval === interval) { return; } window.clearInterval(this._timer); } console.log('%cupdate Interval:%s', 'background: lightblue;', interval); this._currentInterval = interval; this._timer = window.setInterval(this._callback, interval); } pause() { window.clearInterval(this._timer); this._timer = null; } start() { if (typeof this._currentInterval !== 'number') { return; } this.changeInterval(this._currentInterval); } } const GamePadModel = ((Emitter) => { class GamePad extends Emitter { constructor(gamepadStatus) { super(); this._gamepadStatus = gamepadStatus; this._buttons = []; this._axes = []; this._pov = ''; this._lastTimestamp = 0; this._povRepeat = 0; this.initialize(gamepadStatus); } get isConnected() { return this._gamepadStatus.connected ? true : false; } get deviceId() { return this._id; } get deviceIndex() { return this._index; } get buttonCount() { return this._buttons ? this._buttons.length : 0; } get axisCount() { return this._axes ? this._axes.length : 0; } get pov() { return this._pov; } get x() { return this._axes.length > 0 ? this._axes[0] : 0; } get y() { return this._axes.length > 1 ? this._axes[1] : 0; } get z() { return this._axes.length > 2 ? this._axes[2] : 0; } initialize(gamepadStatus) { this._buttons.length = gamepadStatus.buttons.length; this._axes.length = gamepadStatus.axes.length; this._id = gamepadStatus.id; this._index = gamepadStatus.index; this._isRepeating = false; this.reset(); } reset() { this._pov = ''; this._povRepeat = 0; for (let i = 0, len = this._gamepadStatus.buttons.length + 16; i < len; i++) { this._buttons[i] = {pressed: false, repeat: 0}; } for (let i = 0, len = this._gamepadStatus.axes.length + 16; i < len; i++) { this._axes[i] = {value: null, repeat: 0}; } } update() { const gamepadStatus = (navigator.getGamepads())[this._index]; // gp || this._gamepadStatus; if (!gamepadStatus) { console.log('no status'); return; } if (!this._isRepeating && this._lastTimestamp === gamepadStatus.timestamp) { return; } this._gamepadStatus = gamepadStatus; this._lastTimestamp = gamepadStatus.timestamp; const buttons = gamepadStatus.buttons, axes = gamepadStatus.axes; let isRepeating = false; for (let i = 0, len = Math.min(this._buttons.length, buttons.length); i < len; i++) { const buttonStatus = buttons[i].pressed ? 1 : 0; if (this._buttons[i].pressed !== buttonStatus) { const eventName = (buttonStatus === 1) ? 'onButtonDown' : 'onButtonUp'; //console.log('%cbutton%s:%s', 'background: lightblue;', i, buttonStatus, 0); this.emit(eventName, i, 0); this.emit('onButtonStatusChange', i, buttonStatus); } this._buttons[i].pressed = buttonStatus; if (buttonStatus) { this._buttons[i].repeat++; isRepeating = true; if (this._buttons[i].repeat % 5 === 0) { //console.log('%cbuttonRepeat%s', 'background: lightblue;', i); this.emit('onButtonRepeat', i); } } else { this._buttons[i].repeat = 0; } } for (let i = 0, len = Math.min(8, this._axes.length); i < len; i++) { const axis = Math.round(axes[i] * 1000) / 1000; if (this._axes[i].value === null) { this._axes[i].value = axis; continue; } const diff = Math.round(Math.abs(axis - this._axes[i].value)); if (diff >= 1) { this.emit('onAxisChange', i, axis); } if (Math.abs(axis) <= 0.1 && this._axes[i].repeat > 0) { this._axes[i].repeat = 0; } else if (Math.abs(axis) > 0.1) { this._axes[i].repeat++; isRepeating = true; } else { this._axes[i].repeat = 0; } this._axes[i].value = axis; } if (typeof axes[9] !== 'number') { this._isRepeating = isRepeating; return; } let pov = ''; if (this._id.match(/Vendor: 057e Product: 200[67]/i)) { const b = 100000; const axis = Math.trunc(axes[9] * b); const margin = b / 10; const AxisMap = this._id.match(/Vendor: 057e Product: 2006/i) ? AxisMapJoyConL : AxisMapJoyConR; if (Math.abs(JoyConAxisCenter * b - axis) <= margin) { pov = ''; } else { Object.keys(AxisMap).forEach(key => { if (Math.abs(AxisMap[key] * b - axis) <= margin) { pov = key; } }); } } else if (this._id.match(/Vendor: (3810|05a0|1235|1002)/i)) { const p = Math.round(axes[9] * 1000); if (p < -500) { pov = 'UP'; } else if (p < 0) { pov = 'RIGHT'; } else if (p > 3000) { pov = ''; } else if (p > 500){ pov = 'LEFT'; } else { pov = 'DOWN'; } } if (this._pov !== pov) { this._pov = pov; this._povRepeat = 0; isRepeating = pov !== ''; this.emit('onPovChange', this._pov); } else if (pov !== '') { this._povRepeat++; isRepeating = true; if (this._povRepeat % 5 === 0) { this.emit('onPovRepeat', this._pov); } } this._isRepeating = isRepeating; //console.log(JSON.stringify(this.dump())); } dump() { const gamepadStatus = this._gamepadStatus, buttons = gamepadStatus.buttons, axes = gamepadStatus.axes; const btmp = [], atmp = []; for (let i = 0, len = axes.length; i < len; i++) { atmp.push('ax' + i + ': ' + axes[i]); } for (let i = 0, len = buttons.length; i < len; i++) { btmp.push('bt' + i + ': ' + (buttons[i].pressed ? 1 : 0)); } return atmp.join('\n') + '\n' + btmp.join(', '); } getX() { return this._axes.length > 0 ? this._axes[0] : 0; } getY() { return this._axes.length > 1 ? this._axes[1] : 0; } getZ() { return this._axes.length > 2 ? this._axes[2] : 0; } getButtonCount() { return this._buttons ? this._buttons.length : 0; } getButtonStatus(index) { return this._buttons[index] || 0; } getAxisCount() { return this._axes ? this._axes.length : 0; } getAxisValue(index) { return this._axes[index] || 0; } getDeviceIndex() { return this._index; } getDeviceId() { return this._id; } getPov() { return this._pov; } release() { // TODO: clear events } } return GamePad; })(Emitter); const ZenzaGamePad = (($, PollingTimer, GamePadModel) => { let activeGamepad = null; let pollingTimer = null; let ZenzaGamePad = ZenzaWatch.modules ? new ZenzaWatch.modules.Emitter() : new Emitter(); const padIndex = Config.get('deviceIndex') * 1; const detectGamepad = () => { if (activeGamepad) { return; } const gamepads = navigator.getGamepads(); if (gamepads.length < 1) { return; } const pad = Array.from(gamepads).find(pad => { return pad && pad.connected && pad.id && pad.index === padIndex && // windowsにDualShock4を繋ぐとあらわれる謎のデバイス !pad.id.match(/Vendor: 00ff/i); }); if (!pad) { return; } window.console.log( '%cdetect gamepad index: %s, id: "%s"', 'background: lightgreen; font-weight: bolder;', pad.index, pad.id ); const gamepad = new GamePadModel(pad); activeGamepad = gamepad; const self = ZenzaGamePad; const onButtonDown = number => { self.emit('onButtonDown', number, gamepad.deviceIndex); }; const onButtonRepeat = number => { self.emit('onButtonRepeat', number, gamepad.deviceIndex); }; const onButtonUp = number => { self.emit('onButtonUp', number, gamepad.deviceIndex); }; const onAxisChange = (number, value) => { self.emit('onAxisChange', number, value, gamepad.deviceIndex); }; const onAxisRepeat = (number, value) => { self.emit('onAxisRepeat', number, value, gamepad.deviceIndex); }; const onAxisRelease = number => { self.emit('onAxisRelease', number, gamepad.deviceIndex); }; const onPovChange = pov => { self.emit('onPovChange', pov, gamepad.deviceIndex); }; const onPovRepeat = pov => { self.emit('onPovRepeat', pov, gamepad.deviceIndex); }; gamepad.on('onButtonDown', onButtonDown); gamepad.on('onButtonRepeat', onButtonRepeat); gamepad.on('onButtonUp', onButtonUp); gamepad.on('onAxisChange', onAxisChange); gamepad.on('onAxisRepeat', onAxisRepeat); gamepad.on('onAxisRelease', onAxisRelease); gamepad.on('onPovChange', onPovChange); gamepad.on('onPovRepeat', onPovRepeat); self.emit('onDeviceConnect', gamepad.getDeviceIndex(), gamepad.getDeviceId()); pollingTimer.changeInterval(30); }; const onGamepadConnectStatusChange = (e, isConnected) => { const padIndex = Config.get('deviceIndex') * 1; console.log('onGamepadConnetcStatusChange', e, e.gamepad.index, isConnected); if (e.gamepad.index !== padIndex) { return; } if (isConnected) { console.log('%cgamepad connected id:"%s"', 'background: lightblue;', e.gamepad.id); detectGamepad(); } else { ZenzaGamePad.emit('onDeviceDisconnect', activeGamepad.getDeviceIndex()); if (activeGamepad) { activeGamepad.release(); } activeGamepad = null; console.log('%cgamepad disconneced id:"%s"', 'background: lightblue;', e.gamepad.id); } }; const initializeTimer = () => { console.log('%cinitializeGamepadTimer', 'background: lightgreen;'); const onTimerInterval = () => { if (!Config.get('enabled')) { return; } if (Config.get('needFocus') && !document.hasFocus()) { return; } if (!activeGamepad) { detectGamepad(); return; } if (!activeGamepad.isConnected) { return; } activeGamepad.update(); }; pollingTimer = new PollingTimer(onTimerInterval, 1000); }; const initializeGamepadConnectEvent = () => { console.log('%cinitializeGamepadConnectEvent', 'background: lightgreen;'); window.addEventListener('gamepadconnected', e => onGamepadConnectStatusChange(e, true)); window.addEventListener('gamepaddisconnected', e => onGamepadConnectStatusChange(e, false)); if (activeGamepad) { return; } window.setTimeout(detectGamepad, 1000); }; ZenzaGamePad.startDetect = () => { ZenzaGamePad.startDetect = _.noop; initializeTimer(); initializeGamepadConnectEvent(); }; ZenzaGamePad.startPolling = () => { if (pollingTimer) { pollingTimer.start(); } }; ZenzaGamePad.stopPolling = () => { if (pollingTimer) { pollingTimer.pause(); } }; return ZenzaGamePad; })($, PollingTimer, GamePadModel); let hasInitGamePad = false; const initGamePad = () => { if (hasInitGamePad) { return; } hasInitGamePad = true; let isActivated = false; let isDetected = false; let deviceId, deviceIndex; const notifyDetect = () => { if (!document.hasFocus() || isDetected) { return; } isActivated = true; isDetected = true; // 初めてボタンかキーが押されたタイミングで通知する execCommand( 'notify', 'ゲームパッド "' + deviceId + '" が検出されました' ); }; const _onButtonDown = number => { notifyDetect(); if (!isActivated) { return; } onButtonDown(number, deviceId); }; const _onButtonRepeat = number => { if (!isActivated) { return; } onButtonRepeat(number, deviceId); }; const _onButtonUp = number => { if (!isActivated) { return; } onButtonUp(number, deviceId); }; const _onAxisChange = (number, value) => { notifyDetect(); if (!isActivated) { return; } onAxisChange(number, value, deviceId); }; const _onAxisRepeat = (number, value) => { if (!isActivated) { return; } onAxisRepeat(number, value, deviceId); }; const _onAxisRelease = () => { if (!isActivated) { return; } }; const _onPovChange = pov => { notifyDetect(); if (!isActivated) { return; } onPovChange(pov, deviceId); }; const _onPovRepeat = pov => { if (!isActivated) { return; } onPovRepeat(pov, deviceId); }; let hasBound = false; const bindEvents = () => { if (hasBound) { return; } hasBound = true; ZenzaGamePad.on('onButtonDown', _onButtonDown); ZenzaGamePad.on('onButtonRepeat', _onButtonRepeat); ZenzaGamePad.on('onButtonUp', _onButtonUp); ZenzaGamePad.on('onAxisChange', _onAxisChange); ZenzaGamePad.on('onAxisRepeat', _onAxisRepeat); ZenzaGamePad.on('onAxisRelease', _onAxisRelease); ZenzaGamePad.on('onPovChange', _onPovChange); ZenzaGamePad.on('onPovRepeat', _onPovRepeat); }; const onDeviceConnect = (index, id) => { deviceIndex = index; deviceId = id; bindEvents(); }; ZenzaGamePad.on('onDeviceConnect', onDeviceConnect); //ZenzaGamePad.on('onDeviceDisConnect', onDeviceDisConnect); ZenzaGamePad.startDetect(); window.ZenzaWatch.ZenzaGamePad = ZenzaGamePad; }; const onZenzaWatchOpen = () => { isZenzaWatchOpen = true; initGamePad(); ZenzaGamePad.startPolling(); }; const onZenzaWatchClose = () => { isZenzaWatchOpen = false; ZenzaGamePad.stopPolling(); }; const initialize = async () => { ZenzaWatch.emitter.on('DialogPlayerOpen', onZenzaWatchOpen); ZenzaWatch.emitter.on('DialogPlayerClose', onZenzaWatchClose); const initButton = (container, handler) => { ZenzaGamePad.configPanel = new ConfigPanel({parentNode: document.querySelector('#zenzaVideoPlayerDialog')}); const toggleButton = new ToggleButton({parentNode: container}); toggleButton.on('command', handler); toggleButton.refresh(); }; if (ZenzaWatch.emitter.promise) { const {container, handler} = await ZenzaWatch.emitter.promise('videoControBar.addonMenuReady'); initButton(container, handler); } else { ZenzaWatch.emitter.on('videoControBar.addonMenuReady', initButton); } ZenzaWatch.emitter.on('command-toggleZenzaGamePadConfig', () => { ZenzaGamePad.configPanel.toggle(); }); }; initialize(); }; const loadMonkey = () => { const script = document.createElement('script'); script.id = 'ZenzaGamePadLoader'; script.setAttribute('type', 'text/javascript'); script.setAttribute('charset', 'UTF-8'); script.append(`(${monkey})(window.ZenzaWatch);`); document.head.append(script); }; const ZenzaDetector = (() => { const promise = (window.ZenzaWatch && window.ZenzaWatch.ready) ? Promise.resolve(window.ZenzaWatch) : new Promise(resolve => { [window, (document.body || document.documentElement)] .forEach(e => e.addEventListener('ZenzaWatchInitialize', () => { resolve(window.ZenzaWatch); })); }); return {detect: () => promise}; })(); await ZenzaDetector.detect(); loadMonkey(); })(globalThis ? globalThis.window : window);