Greasy Fork is available in English.

Video AB Repeater

動画のABリピート機能 ZenzaWatch用

// ==UserScript==
// @name        Video AB Repeater
// @namespace   https://github.com/segabito/
// @description 動画のABリピート機能 ZenzaWatch用
// @match       *://www.nicovideo.jp/*
// @match       *://ext.nicovideo.jp/
// @match       *://ext.nicovideo.jp/#*
// @match       *://ch.nicovideo.jp/*
// @match       *://com.nicovideo.jp/*
// @match       *://commons.nicovideo.jp/*
// @match       *://dic.nicovideo.jp/*
// @exclude     *://ads*.nicovideo.jp/*
// @exclude     *://www.upload.nicovideo.jp/*
// @exclude     *://www.nicovideo.jp/watch/*?edit=*
// @exclude     *://ch.nicovideo.jp/tool/*
// @exclude     *://flapi.nicovideo.jp/*
// @exclude     *://dic.nicovideo.jp/p/*
// @include     *://www.youtube.com/*
// @version     0.0.7
// @grant       none
// @author      segabito macmoto
// @license     public domain
// @noframes
// @require        https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.1/lodash.js
// @require        https://cdnjs.cloudflare.com/ajax/libs/fetch/2.0.1/fetch.js
// ==/UserScript==



(() => {
  const PRODUCT = 'Repeater';
  const monkey = function(PRODUCT) {
    const console = window.console;
    let ZenzaWatch = null;
    console.log(`exec ${PRODUCT}..`);

    const CONSTANT = {
      BASE_Z_INDEX: 150000
    };
    const product = {debug: {_const: CONSTANT}};
    window[PRODUCT] = product;

    const {util, Emitter} = (function() {
      const util = {};
      class Emitter {

        on(name, callback) {
          if (!this._events) { this._events = {}; }
          name = name.toLowerCase();
          if (!this._events[name]) {
            this._events[name] = [];
          }
          this._events[name].push(callback);
        }

        clear(name) {
          if (!this._events) { this._events = {}; }
          if (name) {
            this._events[name] = [];
          } else {
            this._events = {};
          }
        }

        emit(name) {
          if (!this._events) { this._events = {}; }
          name = name.toLowerCase();
          if (!this._events.hasOwnProperty(name)) { return; }
          const e = this._events[name];
          const arg = Array.prototype.slice.call(arguments, 1);
          for (let i =0, len = e.length; i < len; i++) {
            e[i].apply(null, arg);
          }
        }

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

      util.emitter = new Emitter();

      return {util, Emitter};
    })(PRODUCT);
    product.util = util;

    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 RepeaterRange extends BaseViewComponent {
      constructor({parentNode, repeater}) {
        super({
          parentNode,
          name: 'VideoABRepeaterRange',
          shadow: RepeaterRange.__shadow__,
          template: '<div class="RepeaterRange"></div>',
          css: ''
        });

        this._a = -1;
        this._b = -1;
        this._repeater = repeater;
        repeater.on('range', () => {
          this.setA(repeater.getA());
          this.setB(repeater.getB());
          this.refresh();
        });
      }

      get _duration() {
        return this._repeater.duration;
      }

      setA(v) {
        this._a = v;
      }

      setB(v) {
        this._b = v;
      }

      _reset() {
        this._shadow.style.display = 'none';
        this._a = -1;
        this._b = -1;
      }

      onVideoChange() {
        this._reset();
      }

      _timeToPer(time) {
        return (time / Math.max(this._duration, 1)) * 100;
      }

      refresh() {
        this._shadow.style.display = this._b < 0 ? 'none': '';
        if (this._b < 0) {
          return;
        }
        const perLeft = (this._timeToPer(this._a));
        const scaleX = this._timeToPer(this._b - this._a) / 100;
        this._shadow.style.transform =
          `translate3d(${perLeft}%, 0, 0) scaleX(${scaleX})`;
        this._shadow.setAttribute('data-pos', `a: ${this._a}, b: ${this._b}`);
      }
    }

    RepeaterRange.__shadow__ = `
      <style>
        .root {
          pointer-events: none;
          position: absolute;
          width: 100vw;
          height: 100%;
          left: 0px;
          top: 0%;
          /*box-shadow: 0 0 6px #333 inset, 0 0 4px #333;*/
          z-index: 100;
          background: rgba(255, 255, 90, 0.5);
          transform-origin: left;
          transform: translate3d(0, 0, 0) scaleX(0);
          transition: transform 0.2s;
          outline: 2px solid orange;
        }

        :host-context(.zenzaStoryboardOpen) .VideoAPRepeaterRange {
          background: #ff9;
          mix-blend-mode: lighten;
          opacity: 0.5;
        }

      </style>
      <div class="VideoABRepeaterRange root"></div>
    `.trim();

    class ContextMenuButton extends BaseViewComponent {
      constructor({parentNode, repeater}) {
        super({
          parentNode,
          name: 'ContextMenuButton',
          shadow: ContextMenuButton.__shadow__,
          template: '<div class="ContextMenuButton"></div>',
          css: ''
        });
      }

      _initDom(...args) {
        super._initDom(...args);

        const root = this._shadowRoot;
        root.addEventListener('mousedown', e => {
          e.preventDefault(); e.stopPropagation();
        });
      }
    }

    ContextMenuButton.__shadow__ = `
      <style>
        .root {
          white-space: nowrap;
          display: flex;
        }

        :host-context(.zenzaStoryboardOpen) .VideoAPRepeaterRange {
          background: #ff9;
          mix-blend-mode: lighten;
          opacity: 0.5;
        }

        .controlButton {
          width: 33%;
          margin: 0;
          padding: 0;
          flex: 1;
          height: 48px;
          font-size: 24px;
          line-height: 46px;
          border: 1px solid;
          border-radius: 4px;
          color: #333;
          background: rgba(192, 192, 192, 0.95);
          cursor: pointer;
          transition: transform 0.1s, box-shadow 0.1s;
          box-shadow: 0 0 0;
          opacity: 1;
          margin: auto;
          font-family: 'Arial Black';
        }

        .controlButton:hover {
          transform: translate(0px, -4px);
          box-shadow: 0px 4px 2px #666;
        }

        .controlButton:active {
          transform: none;
          box-shadow: 0 0 0;
          border: 1px inset;
        }

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

        :host-context(.is-mouseMoving) .controlButton:hover .tooltip {
          display: block;
          opacity: 1;
        }

      </style>
      <div class="root">
        <button class="controlButton command" data-command="setA">
          A
          <div class="tooltip">リピート開始点</div>
        </button>
        <button class="controlButton command" data-command="setB">
          B
          <div class="tooltip">リピート終了点</div>
        </button>
        <button class="controlButton command" data-command="reset">
          *
          <div class="tooltip">リピート解除</div>
        </button>
      </div>
    `.trim();




    const ZenzaDetector = (function() {
      let isReady = false;
      const emitter = new Emitter();

      const onZenzaReady = () => {
        isReady = true;
        ZenzaWatch = window.ZenzaWatch;

        emitter.emit('ready', window.ZenzaWatch);
      };

      if (window.ZenzaWatch && window.ZenzaWatch.ready) {
        window.console.log('ZenzaWatch is Ready');
        isReady = true;
      } else {
        document.body.addEventListener('ZenzaWatchInitialize', () => {
          window.console.log('ZenzaWatchInitialize');
          onZenzaReady();
        });
      }

      const detect = function() {
        return new Promise(res => {
          if (isReady) {
            return res(window.ZenzaWatch);
          }
          emitter.on('ready', () => {
            res(window.ZenzaWatch);
          });
        });
      };

      return {detect};
    })();


    class Repeater extends Emitter {
      constructor(params) {
        super();
        this._timer = null;
        this._videoTime = params.videoTime;
        this._notifier = params.notifier;
      }

      setA() {
        this._a = this.currentTime;
        if (this._b < this._a) {
          this._b = this.duration + 1;
        }
        this.notify('set "A"');
        this.emit('range');
        this.enable();
      }

      getA() {
        return this._a;
      }

      setB() {
        this._b = this.currentTime;
        if (this._b < this._a || this._a < 0) {
          this._a = Math.max(this._b - 30, 0);
        }
        this.emit('range');
        this.notify('set "B"');
        this.enable();
      }

      getB() {
        return this._b;
      }

      resetA() {
        this._a = -1;
        this.notify('reset "A"');
        this.emit('range');
      }

      resetB() {
        this._b = this.duration;
        this.notify('reset "B"');
        this.emit('range');
      }

      jumpToA() {
        this.currentTime = Math.max(this._a, 0);
      }

      _reset() {
        this._a = -1;
        this._b = -1;
        this.emit('range');
      }

      reset() {
        this._reset();
      }

      get currentTime() {
        return this._videoTime.get();
      }

      set currentTime(v) {
        this._videoTime.set(v);
      }

      get duration() {
        return this._videoTime.duration();
      }

      notify(msg) {
        this._notifier.notify(msg);
      }

      enable() {
        if (this._timer) { return; }
        console.info('start timer', this._timer, this._rate);
        this._timer = setInterval(this._onTimer.bind(this), 100);
      }

      disable() {
        clearInterval(this._timer);
        this._reset();
        this._timer = null;
      }

      onVideoChange() {
        this._reset();
      }

      _onTimer() {
        if (this._b < 0) { return; }
        if (this.currentTime > this._b) {
          this.currentTime = Math.max(this._a, 0);
        }
      }

    }

    const KeyEmitter = (() => {
      const emitter = new Emitter();

      const onKeyDown = e => {
        if (e.target.tagName === 'SELECT' ||
            e.target.tagName === 'INPUT' ||
            e.target.tagName === 'TEXTAREA') {
          return;
        }

        let keyCode = e.keyCode +
          (e.metaKey  ? 0x1000000 : 0) +
          (e.altKey   ? 0x100000  : 0) +
          (e.ctrlKey  ? 0x10000   : 0) +
          (e.shiftKey ? 0x1000    : 0);
        emitter.emit('keydown', keyCode);
      };

      const onKeyUp = e => {
        if (e.target.tagName === 'SELECT' ||
            e.target.tagName === 'INPUT' ||
            e.target.tagName === 'TEXTAREA') {
          return;
        }

        let keyCode = e.keyCode +
          (e.metaKey  ? 0x1000000 : 0) +
          (e.altKey   ? 0x100000  : 0) +
          (e.ctrlKey  ? 0x10000   : 0) +
          (e.shiftKey ? 0x1000    : 0);
        switch (keyCode) {
        }
        emitter.emit('keyup', keyCode);
      };

      let initialize = () => {
        initialize = () => {};
        document.body.addEventListener('keydown', onKeyDown);
        document.body.addEventListener('keyup', onKeyUp);
      };

      return {
        on: (...args) => { emitter.on(...args); },
        off: (...args) => { emitter.off(...args); },
        initialize
      };
    })();


    const initExternal = (repeater, range) => {
      product.external = {
        repeater
      };

      product.isReady = true;
      const ev = new CustomEvent(`${PRODUCT}Initialized`, { detail: { product } });
      document.body.dispatchEvent(ev);
    };

    const initKey = repeater => {
      KeyEmitter.initialize();

      KeyEmitter.on('keydown', code => {
        switch (code) {
          case 219: // [
            repeater.setA();
            //if (range) {
            //  range.setA(repeater.getA());
            //  range.setB(repeater.getB());
            //}
            break;
          case 221: // ]
            repeater.setB();
            //if (range) {
            //  range.setA(repeater.getA());
            //  range.setB(repeater.getB());
            //}
            break;
          case 219 + 0x1000:
            repeater.jumpToA();
            break;
          case 221 + 0x1000:
            repeater.reset();
            //if (range) {
            //  range.setA(repeater.getA());
            //  range.setB(repeater.getB());
            //}
            break;
        }
      });
    };

    const initNico = () => {
      let repeater, range, control;
      ZenzaDetector.detect().then(() => {
        const ZenzaWatch = window.ZenzaWatch;
        const videoTime = {
          get: () => {
            return ZenzaWatch.external.getVideoElement().currentTime;
          },
          set: (v) => {
            ZenzaWatch.external.getVideoElement().currentTime = v;
          },
          duration: () => {
            return ZenzaWatch.external.getVideoElement().duration;
          }
        };
        const notifier = {
          notify: msg => {
            //ZenzaWatch.external.execCommand('notify', msg);
          }
        };

        let dialog;
        ZenzaWatch.emitter.on('DialogPlayerOpen', () => {
          if (!dialog) {
            dialog = ZenzaWatch.debug.dialog;
            dialog.on('loadVideoInfo', () => {
              repeater.onVideoChange();
            });
          }
        });

        ZenzaWatch.emitter.on('DialogPlayerClose', () => {
          repeater.disable();
        });

        repeater = new Repeater({videoTime, notifier});

        ZenzaWatch.emitter.on('seekBar.addonMenuReady', container => {
          range = new RepeaterRange({parentNode: container, repeater});
          initKey(repeater, range);
          initExternal(repeater);
        });

        ZenzaWatch.emitter.on('videoContextMenu.addonMenuReady', (container, handler) => {
          control = new ContextMenuButton({parentNode: container});
          control.on('command', (command, param) => {
            switch(command) {
              case 'setA':
                repeater.setA();
                break;
              case 'setB':
                repeater.setB();
                break;
              case 'reset':
                repeater.reset();
                document.body.click();
                break;
            }
          });
        });

      });
    };

    const initTube = () => {
      let video = document.querySelector('video.html5-main-video');
      const getVideoElement = () => {
        if (!video) {
          video = document.querySelector('video.html5-main-video');
        }
        return video;
      };
      const videoTime = {
        get: () => {
          return getVideoElement().currentTime;
        },
        set: (v) => {
          getVideoElement().currentTime = v;
        },
        duration: () => {
          return getVideoElement().duration;
        }
      };

      const notifier = {
        notify: msg => {
          console.log('%c%s', 'background: lightgreen;', msg);
        }
      };
      let lastPath = location.pathname + location.search;

      let repeater = new Repeater({videoTime, notifier});
      initKey(repeater);

      const onUpdatePage = () => {
        if (lastPath !== location.pathname + location.search) {
          lastPath = location.pathname + location.search;

          repeater.onVideoChange();
        }
      };

      window.setInterval(onUpdatePage, 1000);
    };

    if (location.host.match(/^[a-z0-9_-]+\.nicovideo\.jp$/)) {
      initNico();
    } else if (location.host.match(/youtube\.com$/)) {
      initTube();
    }
  };

  (() => {
    const script = document.createElement('script');
    script.id = `${PRODUCT}Loader`;
    script.setAttribute('type', 'text/javascript');
    script.setAttribute('charset', 'UTF-8');
    script.appendChild(document.createTextNode(
      `(${monkey})("${PRODUCT}");`
    ));
    document.body.appendChild(script);
  })();

})();