ZenzaGamePad

ZenzaWatchをゲームパッドで操作

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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">&#x1F3AE;</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);