Greasy Fork is available in English.

JoyRemocon

Nintendo SwitchのJoy-Conを動画プレイヤーのリモコンにする.

// ==UserScript==
// @name        JoyRemocon
// @namespace   https://github.com/segabito/
// @description Nintendo SwitchのJoy-Conを動画プレイヤーのリモコンにする.
// @include     *://*.nicovideo.jp/watch/*
// @include     *://www.youtube.com/*
// @include     *://www.bilibili.com/video/*
// @include     *://www.amazon.co.jp/gp/video/*
// @version     1.6.0
// @author      segabito macmoto
// @license     public domain
// @grant       none
// @noframes
// ==/UserScript==




(() => {

  const monkey = () => {
    if (!window.navigator.getGamepads) {
      window.console.log('%cGamepad APIがサポートされていません', 'background: red; color: yellow;');
      return;
    }

    const PRODUCT = 'JoyRemocon';
    let isPauseButtonDown = false;
    let isRate1ButtonDown = false;
    let isMetaButtonDown = false;

    const getVideo = () => {
      switch (location.host) {
        case 'www.nicovideo.jp':
          return document.querySelector('.MainVideoPlayer video');
        case 'www.amazon.co.jp':
          return document.querySelector('video[width="100%"]');
        default:
          return Array.from(document.querySelectorAll('video')).find(v => {
            return !!v.src;
          });
      }
    };

    const video = {
      get currentTime() {
        try {
          return window.__videoPlayer ?
            __videoplayer.currentTime() : getVideo().currentTime;
        } catch (e) {
          console.warn(e);
          return 0;
        }
      },
      set currentTime(v) {
        try {
          if (v <= video.currentTime && location.host === 'www.nicovideo.jp') {
            return seekNico(v);
          } else if (location.host === 'www.amazon.co.jp') {
            return seekPrimeVideo(v);
          }
          getVideo().currentTime = v;
        } catch (e) {
          console.warn(e);
        }
      },

      get muted() {
        try {
          return getVideo().muted;
        } catch (e) {
          console.warn(e);
          return false;
        }
      },

      set muted(v) {
        try {
          getVideo().muted = v;
        } catch (e) {
          console.warn(e);
        }
      },

      get playbackRate() {
        try {
          return window.__videoPlayer ?
            __videoplayer.playbackRate() : getVideo().playbackRate;
        } catch (e) {
          console.warn(e);
          return 1;
        }
      },
      set playbackRate(v) {
        try {
          if (window.__videoPlayer) {
            window.__videoPlayer.playbackRate(v);
            return;
          }
          getVideo().playbackRate = Math.max(0.01, v);
        } catch (e) {
          console.warn(e);
        }
      },

      get volume() {
        try {
          if (location.host === 'www.nicovideo.jp') {
            return getVolumeNico();
          }
          return getVideo().volume;
        } catch (e) {
          console.warn(e);
          return 1;
        }
      },

      set volume(v) {
        try {
          v = Math.max(0, Math.min(1, v));
          if (location.host === 'www.nicovideo.jp') {
            return setVolumeNico(v);
          }
          getVideo().volume = v;
        } catch (e) {
          console.warn(e);
        }
      },

      get duration() {
        try {
          return getVideo().duration;
        } catch (e) {
          console.warn(e);
          return 1;
        }
      },

      play() {
        try {
          return getVideo().play();
        } catch (e) {
          console.warn(e);
          return Promise.reject();
        }
      },

      pause() {
        try {
          return getVideo().pause();
        } catch (e) {
          console.warn(e);
          return Promise.reject();
        }
      },

      get paused() {
        try {
          return getVideo().paused;
        } catch (e) {
          console.warn(e);
          return true;
        }
      },

    };

    const seekNico = time => {
      const xs = document.querySelector('.SeekBar .XSlider');
      let [min, sec] = document.querySelector`.PlayerPlayTime-duration`.textContent.split(':');
      let duration = min * 60 + sec * 1;
      let left = xs.getBoundingClientRect().left;
      let offsetWidth = xs.offsetWidth;
      let per = time / duration * 100;
      let clientX = offsetWidth * per / 100 + left;
      xs.dispatchEvent(new MouseEvent('mousedown', {clientX}));
      document.dispatchEvent(new MouseEvent('mouseup', {clientX}));
    };

    const setVolumeNico = vol => {
      const xs = document.querySelector('.VolumeBar .XSlider');
      let left = xs.getBoundingClientRect().left;
      let offsetWidth = xs.offsetWidth;
      let per = vol * 100;
      let clientX = offsetWidth * per / 100 + left;
      xs.dispatchEvent(new MouseEvent('mousedown', {clientX}));
      document.dispatchEvent(new MouseEvent('mouseup', {clientX}));
    };

    const seekPrimeVideo = time => {
      const xs = document.querySelector('.seekBar .progressBarContainer');
      xs.closest('.bottomPanelItem').style.display = '';
      let left = xs.getBoundingClientRect().left;
      let offsetWidth = xs.offsetWidth;
      let per = (time - 10) / video.duration * 100; // 何故か10秒分ズレてる?
      let clientX = offsetWidth * per / 100 + left;
      // console.log('seek', video.currentTime, time, left, offsetWidth, per, clientX);
      xs.dispatchEvent(new PointerEvent('pointerdown', {clientX}));
      xs.dispatchEvent(new PointerEvent('pointerup', {clientX}));
    };

    const getVolumeNico = () => {
      try {
        const xp = document.querySelector('.VolumeBar .XSlider .ProgressBar-inner');
        return (xp.style.transform || '1').replace(/scaleX\(([0-9\.]+)\)/, '$1') * 1;
      } catch (e) {
        console.warn(e);
        return 1;
      }
    };

    const execCommand = (command, param) => {
      switch (command) {
        case 'playbackRate':
          video.playbackRate = param;
          break;
        case 'toggle-play': {
          const btn = document.querySelector(
            '.ytp-ad-skip-button, .PlayerPlayButton, .PlayerPauseButton, .html5-main-videom, .bilibili-player-video-btn-start, .pausedOverlay');
          if (btn) {
            if (location.host === 'www.amazon.co.jp') {
              btn.dispatchEvent(new CustomEvent('pointerup'));
            } else {
              btn.click();
            }
          } else
          if (video.paused) {
            video.play();
          } else {
            video.pause();
          }
          break;
        }
        case 'toggle-mute': {
          const btn = document.querySelector(
            '.MuteVideoButton, .UnMuteVideoButton, .ytp-mute-button, .bilibili-player-iconfont-volume-max');
          if (btn) {
            btn.click();
          } else {
            video.muted = !video.muted;
          }
          break;
        }
        case 'seek':
          video.currentTime = param * 1;
          break;
        case 'seekBy':
          video.currentTime += param * 1;
          break;
        case 'seekNextFrame':
          video.currentTime += 1 / 60;
          break;
        case 'seekPrevFrame':
          video.currentTime -= 1 / 60;
          break;
        case 'volumeUp': {
          let v = video.volume;
          let r = v < 0.05 ? 1.3 : 1.1;
          video.volume = Math.max(0.05, v * r + 0.01);
          break;
        }
        case 'volumeDown': {
          let v = video.volume;
          let r = 1 / 1.2;
          video.volume = Math.max(0.01, v * r);
          break;
        }
        case 'toggle-showComment': {
          const btn = document.querySelector('.CommentOnOffButton, .bilibili-player-video-danmaku-switch input');
          if (btn) {
            btn.click();
          }
          break;
        }
        case 'toggle-fullscreen': {
          const btn = document.querySelector(
            '.EnableFullScreenButton, .DisableFullScreenButton, .ytp-fullscreen-button, .bilibili-player-video-btn-fullscreen, .imageButton.fullscreenButton');
          if (btn) {
            btn.click();
          }
          break;
        }
        case 'playNextVideo': {
          const btn = document.querySelector(
            '.PlayerSkipNextButton, .ytp-next-button, .nextTitleButton, .skipAdButton');
          if (btn) {
            btn.click();
          }
          break;
        }
        case 'playPreviousVideo': {
          const btn = document.querySelector(
            '.PlayerSeekBackwardButton');
          if (btn) {
            btn.click();
          }
          if (['www.youtube.com'].includes(location.host)) {
            history.back();
          }
          break;
        }
        case 'screenShot': {
          screenShot();
          break;
        }
        case 'deflistAdd': {
          const btn = document.querySelector(
            '.InstantMylistButton');
          if (btn) {
            btn.click();
          }
          break;
        }
        case 'notify':
          notify(param);
          break;
        case 'unlink':
          if (document.hasFocus()) {
            JoyRemocon.unlink();
          }
          break;
        default:
          console.warn('unknown command "%s" "%o"', command, param);
          break;
      }
    };

    const notify = message => {
      const div = document.createElement('div');
      div.textContent = message;
      Object.assign(div.style, {
        position: 'fixed',
        display: 'inline-block',
        zIndex: 1000000,
        left: 0,
        bottom:  0,
        transition: 'opacity 0.4s linear, transform 0.5s ease',
        padding: '8px 16px',
        background: '#00c',
        color: 'rgba(255, 255, 255, 0.8)',
        fontSize: '16px',
        fontWeight: 'bolder',
        whiteSpace: 'nowrap',
        textAlign: 'center',
        boxShadow: '2px 2px 0 #ccc',
        userSelect: 'none',
        pointerEvents: 'none',
        willChange: 'transform',
        opacity: 0,
        transform: 'translate(0, +100%) translate(48px, +48px) ',
      });


      const parent = document.querySelector('.MainContainer') || document.body;
      parent.append(div);

      setTimeout(() => {
        Object.assign(div.style, { opacity: 1, transform: 'translate(48px, -48px)' });
      }, 100);
      setTimeout(() => {
        Object.assign(div.style, { opacity: 0, transform: 'translate(48px, -48px) scaleY(0)' });
      }, 2000);
      setTimeout(() => {
        div.remove();
      }, 3000);
    };

    const getVideoTitle = () => {
      switch (location.host) {
        case 'www.nicovideo.jp':
          return document.title;
        case 'www.youtube.com':
          return document.title;
        default:
          return document.title;
      }
    };

    const toSafeName = function(text) {
      text = text.trim()
        .replace(/</g, '<')
        .replace(/>/g, '>')
        .replace(/\?/g, '?')
        .replace(/:/g, ':')
        .replace(/\|/g, '|')
        .replace(/\//g, '/')
        .replace(/\\/g, '¥')
        .replace(/"/g, '”')
        .replace(/\./g, '.')
      ;
      return text;
    };

    const speedUp = () => {
      let current = video.playbackRate;
      execCommand('playbackRate', Math.floor(Math.min(current + 0.1, 3) * 10) / 10);
    };

    const speedDown = () => {
      let current = video.playbackRate;
      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 screenShot = video => {
      video = video || getVideo();
      if (!video) {
        return;
      }
      // draw canvas
      const width = video.videoWidth;
      const height = video.videoHeight;
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const context = canvas.getContext('2d');
      context.drawImage(video, 0, 0);
      document.body.append(canvas);
      // fileName
      const videoTitle = getVideoTitle();
      const currentTime = video.currentTime;
      const min = Math.floor(currentTime / 60);
      const sec = (currentTime % 60 + 100).toString().substr(1, 6);
      const time = `${min}_${sec}`;
      const fileName = `${toSafeName(videoTitle)}@${time}.png`;

      // to objectURL
      console.time('canvas to DataURL');
      const dataURL = canvas.toDataURL('image/png');
      console.timeEnd('canvas to DataURL');

      console.time('dataURL to objectURL');
      const bin = atob(dataURL.split(',')[1]);
      const buf = new Uint8Array(bin.length);
      for (let i = 0, len = buf.length; i < len; i++) {
        buf[i] = bin.charCodeAt(i);
      }
      const blob = new Blob([buf.buffer], {type: 'image/png'});
      const objectURL = URL.createObjectURL(blob);
      console.timeEnd('dataURL to objectURL');

      // save
      const link = document.createElement('a');
      link.setAttribute('download', fileName);
      link.setAttribute('href', objectURL);
      document.body.append(link);
      link.click();
      setTimeout(() => { link.remove(); URL.revokeObjectURL(objectURL); }, 1000);
    };

    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,
    };


    const onButtonDown = (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('toggle-play');
          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('unlink');
          } else {
            execCommand('deflistAdd');
          }
          break;
        case ButtonMap.CAP:
          if (location.host === 'www.amazon.co.jp') {
            return;
          }
          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 onButtonUp = (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) => {
      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) => {};
    const onAxisRepeat = (axis, value, deviceId) => {};
    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 Handler {
      constructor(...args) {
        this._list = new Array(...args);
      }

      get length() {
        return this._list.length;
      }

      exec(...args) {
        if (!this._list.length) {
          return;
        } else if (this._list.length === 1) {
          this._list[0](...args);
          return;
        }
        for (let i = this._list.length - 1; i >= 0; i--) {
          this._list[i](...args);
        }
      }

      execMethod(name, ...args) {
        if (!this._list.length) {
          return;
        } else if (this._list.length === 1) {
          this._list[0][name](...args);
          return;
        }
        for (let i = this._list.length - 1; i >= 0; i--) {
          this._list[i][name](...args);
        }
      }

      add(member) {
        if (this._list.includes(member)) {
          return this;
        }
        this._list.unshift(member);
        return this;
      }

      remove(member) {
        _.pull(this._list, member);
        return this;
      }

      clear() {
        this._list.length = 0;
        return this;
      }

      get isEmpty() {
        return this._list.length < 1;
      }
    }


    const {Emitter} = (() => {
      class Emitter {

        on(name, callback) {
          if (!this._events) {
            Emitter.totalCount++;
            this._events = {};
          }

          name = name.toLowerCase();
          let e = this._events[name];
          if (!e) {
            e = this._events[name] = new Handler(callback);
          } else {
            e.add(callback);
          }
          if (e.length > 10) {
            Emitter.warnings.push(this);
          }
          return this;
        }

        off(name, callback) {
          if (!this._events) {
            return;
          }

          name = name.toLowerCase();
          const e = this._events[name];

          if (!this._events[name]) {
            return;
          } else if (!callback) {
            delete this._events[name];
          } else {
            e.remove(callback);

            if (e.isEmpty) {
              delete this._events[name];
            }
          }

          if (Object.keys(this._events).length < 1) {
            delete this._events;
          }
          return this;
        }

        once(name, func) {
          const wrapper = (...args) => {
            func(...args);
            this.off(name, wrapper);
            wrapper._original = null;
          };
          wrapper._original = func;
          return this.on(name, wrapper);
        }

        clear(name) {
          if (!this._events) {
            return;
          }

          if (name) {
            delete this._events[name];
          } else {
            delete this._events;
            Emitter.totalCount--;
          }
          return this;
        }

        emit(name, ...args) {
          if (!this._events) {
            return;
          }

          name = name.toLowerCase();
          const e = this._events[name];

          if (!e) {
            return;
          }

          e.exec(...args);
          return this;
        }

        emitAsync(...args) {
          if (!this._events) {
            return;
          }

          setTimeout(() => {
            this.emit(...args);
          }, 0);
          return this;
        }
      }

      Emitter.totalCount = 0;
      Emitter.warnings = [];

      return {
        Emitter
      };
    })();

    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);
      }
    }

    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);
      }
      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() {
        let i, len;
        this._pov = '';
        this._povRepeat = 0;

        for (i = 0, len = this._gamepadStatus.buttons.length + 16; i < len; i++) {
          this._buttons[i] = {pressed: false, repeat: 0};
        }
        for (i = 0, len = this._gamepadStatus.axes.length; i < len; i++) {
          this._axes[i] = {value: null, repeat: 0};
        }
      }

      update() {
        let gamepadStatus = (navigator.getGamepads())[this._index];

        if (!gamepadStatus || !gamepadStatus.connected) { console.log('no status'); return; }

        if (!this._isRepeating && this._lastTimestamp === gamepadStatus.timestamp) {
          return;
        }
        this._gamepadStatus = gamepadStatus;
        this._lastTimestamp = gamepadStatus.timestamp;

        let buttons = gamepadStatus.buttons, axes = gamepadStatus.axes;
        let i, len, axis, isRepeating = false;

        for (i = 0, len = Math.min(this._buttons.length, buttons.length); i < len; i++) {
          let buttonStatus = buttons[i].pressed ? 1 : 0;

          if (this._buttons[i].pressed !== buttonStatus) {
            let eventName = (buttonStatus === 1) ? 'onButtonDown' : 'onButtonUp';
            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 (i = 0, len = Math.min(8, this._axes.length); i < len; i++) {
          axis = Math.round(axes[i] * 1000) / 1000;

          if (this._axes[i].value === null) {
            this._axes[i].value = axis;
            continue;
          }

          let 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;
        }
        {
          const b = 100000;
          const axis = Math.trunc(axes[9] * b);
          const margin = b / 10;
          let pov = '';
          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;
              }
            });
          }
          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;
      }

      dump() {
        let gamepadStatus = this._gamepadStatus, buttons = gamepadStatus.buttons, axes = gamepadStatus.axes;
        let i, len, btmp = [], atmp = [];
        for (i = 0, len = axes.length; i < len; i++) {
          atmp.push('ax' + i + ': ' + axes[i]);
        }
        for (i = 0, len = buttons.length; i < len; i++) {
          btmp.push('bt' + i + ': ' + (buttons[i].pressed ? 1 : 0));
        }
        return atmp.join('\n') + '\n' + btmp.join(', ');
      }

      getButtonStatus(index) {
        return this._buttons[index] || 0;
      }

      getAxisValue(index) {
        return this._axes[index] || 0;
      }

      release() {
        this.clear();
      }

      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;
      }
    }

    const noop = () => {};

    const JoyRemocon = (() => {
      let activeGamepad = null;
      let pollingTimer = null;
      let emitter = new Emitter();
      let unlinked = false;

      const detectGamepad = () => {
        if (activeGamepad) {
          return;
        }
        const gamepads = navigator.getGamepads();
        if (gamepads.length < 1) {
          return;
        }
        const pad = Array.from(gamepads).reverse().find(pad => {
          return  pad &&
                  pad.connected &&
                  pad.id.match(/^Joy-Con/i);
        });
        if (!pad) { return; }

        window.console.log(
          '%cdetect gamepad index: %s, id: "%s", buttons: %s, axes: %s',
          'background: lightgreen; font-weight: bolder;',
          pad.index, pad.id, pad.buttons.length, pad.axes.length
        );

        const gamepad = new GamePad(pad);
        activeGamepad = gamepad;

        gamepad.on('onButtonDown',
            number => emitter.emit('onButtonDown', number, gamepad.deviceIndex));
        gamepad.on('onButtonRepeat',
            number => emitter.emit('onButtonRepeat', number, gamepad.deviceIndex));
        gamepad.on('onButtonUp',
            number => emitter.emit('onButtonUp', number, gamepad.deviceIndex));
        gamepad.on('onPovChange',
            pov => emitter.emit('onPovChange', pov, gamepad.deviceIndex));
        gamepad.on('onPovRepeat',
            pov => emitter.emit('onPovRepeat', pov, gamepad.deviceIndex));

        emitter.emit('onDeviceConnect', gamepad.deviceIndex, gamepad.deviceId);

        pollingTimer.changeInterval(30);
      };


      const onGamepadConnectStatusChange = (e, isConnected) => {
        console.log('onGamepadConnetcStatusChange', e, e.gamepad.index, isConnected);

        if (isConnected) {
          console.log('%cgamepad connected id:"%s"', 'background: lightblue;', e.gamepad.id);
          detectGamepad();
        } else {
          emitter.emit('onDeviceDisconnect', activegamepad.deviceIndex);
          // 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 (unlinked) {
            return;
          }
          if (!activeGamepad) {
            return detectGamepad();
          }
          if (!activeGamepad.isConnected) {
            return;
          }
          activeGamepad.update();
        };

        pollingTimer = new PollingTimer(onTimerInterval, 1000);
      };

      const initializeGamepadConnectEvent = () => {
        console.log('%cinitializeGamepadConnectEvent', 'background: lightgreen;');

        window.addEventListener('gamepadconnected',
          function(e) { onGamepadConnectStatusChange(e, true); });
        window.addEventListener('gamepaddisconnected',
          function(e) { onGamepadConnectStatusChange(e, false); });

        if (activeGamepad) {
          return;
        }
        window.setTimeout(detectGamepad, 1000);
      };


      let hasStartDetect = false;
      return {
        on: (...args) => { emitter.on(...args); },
        startDetect: () => {
          if (hasStartDetect) { return; }
          hasStartDetect = true;
          initializeTimer();
          initializeGamepadConnectEvent();
        },
        startPolling: () => {
          if (pollingTimer) { pollingTimer.start(); }
        },
        stopPolling: () => {
          if (pollingTimer) { pollingTimer.pause(); }
        },
        unlink: () => {
          if (!activeGamepad) {
            return;
          }
          unlinked = true;
          activeGamepad.release();
          activeGamepad = null;
          pollingTimer.changeInterval(1000);
          execCommand(
            'notify',
            'JoyRemocon と切断しました'
          );
          }
      };
    })();


    const initGamepad = () => {

      let isActivated = false;
      let deviceId, deviceIndex;

      let notifyDetect = () =>  {
        if (!document.hasFocus()) { return; }
        isActivated = true;
        notifyDetect = noop;

        // 初めてボタンかキーが押されたタイミングで通知する
        execCommand(
          'notify',
          'ゲームパッド "' + deviceId + '" とリンクしました'
        );
      };


      let bindEvents = () => {
        bindEvents = noop;

        JoyRemocon.on('onButtonDown',   number => {
          notifyDetect();
          if (!isActivated) { return; }
          onButtonDown(number, deviceId);
        });
        JoyRemocon.on('onButtonRepeat', number => {
          if (!isActivated) { return; }
          onButtonRepeat(number, deviceId);
        });
        JoyRemocon.on('onButtonUp',     number => {
          if (!isActivated) { return; }
          onButtonUp(number, deviceId);
        });
        JoyRemocon.on('onPovChange',   pov => {
          if (!isActivated) { return; }
          onPovChange(pov, deviceId);
        });
        JoyRemocon.on('onPovRepeat',   pov => {
          if (!isActivated) { return; }
          onPovRepeat(pov, deviceId);
        });
      };

      let onDeviceConnect = function(index, id) {
         deviceIndex = index;
         deviceId = id;

         bindEvents();
      };

      JoyRemocon.on('onDeviceConnect', onDeviceConnect);
      JoyRemocon.startDetect();
    };


    const initialize = () => {
      initGamepad();
    };

    initialize();
  };

  const script = document.createElement('script');
  script.id = 'JoyRemoconLoader';
  script.setAttribute('type', 'text/javascript');
  script.setAttribute('charset', 'UTF-8');
  script.appendChild(document.createTextNode(`(${monkey})();`));
  document.documentElement.append(script);

})();