MylistPocket

動画を「あとで見る」ツール。 ZenzaWatchとの連携も可能

当前为 2016-10-13 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        MylistPocket
// @namespace   https://github.com/segabito/
// @description 動画を「あとで見る」ツール。 ZenzaWatchとの連携も可能
// @match       http://www.nicovideo.jp/*
// @match       http://ext.nicovideo.jp/
// @match       http://ext.nicovideo.jp/#*
// @match       http://ch.nicovideo.jp/*
// @match       http://com.nicovideo.jp/*
// @match       http://commons.nicovideo.jp/*
// @match       http://dic.nicovideo.jp/*
// @match       http://ex.nicovideo.jp/*
// @match       http://info.nicovideo.jp/*
// @match       http://search.nicovideo.jp/*
// @match       http://uad.nicovideo.jp/*
// @exclude     http://ads*.nicovideo.jp/*
// @exclude     http://www.upload.nicovideo.jp/*
// @exclude     http://www.nicovideo.jp/watch/*?edit=*
// @exclude     http://ch.nicovideo.jp/tool/*
// @exclude     http://flapi.nicovideo.jp/*
// @exclude     http://dic.nicovideo.jp/p/*
// @version     0.0.4
// @grant       none
// @author      segabito macmoto
// @license     public domain
// @require     https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.1/lodash.js
// ==/UserScript==

(function() {
  const PRODUCT = 'MylistPocket';

  const monkey = function() {
    const console = window.console;
    console.log('exec MylistPocket..');
    const $ = window.mpJQuery || window.jQuery, _ = window._;
    const TOKEN = 'r:' + (Math.random());
    const PRODUCT = 'MylistPocket';

    const CONSTANT = {
      BASE_Z_INDEX: 100000
    };
    const MylistPocket = {debug: {}};
    window.MylistPocket = MylistPocket;

    const __css__ = (`
      a[href*='watch/'] {
        display: inline-block;
      }

      .mylistPocketHoverMenu {
        display: none;
        opacity: 0.8;
        position: absolute;
        z-index: ${CONSTANT.BASE_Z_INDEX + 100000};
        font-size: 8pt;
        padding: 0;
        line-height: 26px;
        font-weight: bold;
        text-align: center;
        transition: box-shadow 0.2s ease, opacity 0.4s ease, padding 0.2s ease;
        user-select: none;
        -webkit-user-select: none;
        -moz-user-select: none;
      }

      .mylistPocketHoverMenu.is-busy {
        opacity: 0 !important;
        pointer-events: none;
      }
        .mylistPocketHoverMenu.is-otherDomain .wwwOnly {
          display: none;
        }
        .mylistPocketHoverMenu.is-otherDomain:not(.is-zenzaReady) .wwwZenzaOnly {
          display: none;
        }



      .mylistPocketButton {
        /*font-family: Menlo;*/
        display: block;
        font-weight: bolder;
        cursor: pointer;
        width: 32px;
        height: 26px;
        cursor: pointer;
        box-shadow: 1px 1px 1px #000;
        transition:
          0.1s box-shadow ease,
          0.1s transform ease;
        font-size: 16px;
        line-height: 24px;
        -webkit-user-select: none;
        -moz-use-select: none;
        user-select: none;
        outline: none;
      }

      .mylistPocketButton:hover {
        transform: scale(1.2);
        box-shadow: 4px 4px 5px #000;
      }

      .mylistPocketButton:active {
        transform: scale(1.0);
        box-shadow: none;
        transition: none;
      }

      .is-deflistUpdating .mylistPocketButton.deflist-add::after,
      .is-deflistSuccess  .mylistPocketButton.deflist-add::after,
      .is-deflistFail     .mylistPocketButton.deflist-add::after,
      .mylistPocketButton:hover::after, #mylistPocket-poupup [tooltip] {
        content: attr(tooltip);
        position: absolute;
        /*top:  0px;
        left: 50%;*/
        top:  50%;
        right: -8px;
        padding: 2px 4px;
        white-space: nowrap;
        font-size: 12px;
        color: #fff;
        background: #333;
        transform: translate3d(-50%, -120%, 0);
        transform: translate3d(100%, -50%, 0);
        pointer-events: none;
      }

      .is-deflistUpdating .mylistPocketButton.deflist-add {
        cursor: wait;
        opacity: 0.9;
        transform: scale(1.0);
        box-shadow: none;
        transition: none;
        background: #888;
        border-style: inset;
      }
      .is-deflistSuccess .mylistPocketButton.deflist-add,
      .is-deflistFail    .mylistPocketButton.deflist-add {
        transform: scale(1.0);
        box-shadow: none;
        transition: none;
      }
      .is-deflistSuccess  .mylistPocketButton.deflist-add::after {
        content: attr(data-result);
        background: #393;
      }
      .is-deflistFail     .mylistPocketButton.deflist-add::after {
        content: attr(data-result);
        background: #933;
      }
      .is-deflistUpdating .mylistPocketButton.deflist-add::after {
        content: '更新中';
        background: #333;
      }

      .mylistPocketButton + .mylistPocketButton {
        margin-top: 4px;
      }

      .mylistPocketHoverMenu:hover {
        font-weibht: bolder;
        opacity: 1;
      }

      .mylistPocketHoverMenu:active {
      }

      .mylistPocketHoverMenu.is-show {
        display: block;
      }

      #mylistPocket-popup .owner-icon {
        width: 64px;
        height: 64px;
        transform-origin: center;
        transform-origin: center;
        transition:
          0.2s transform ease,
          0.2s box-shadow ease
        ;
      }
      #mylistPocket-popup .owner-icon:hover {
      }

      #mylistPocket-popup .description a {
        color: #ffff00 !important;
        text-decoration: none !important;
        font-weight: normal !important;
        display: inline-block;
      }
      #mylistPocket-popup .description a.watch {
        display: block;
      }

      #mylistPocket-popup .description a:visited {
        color: #ffff99 !important;
      }
      #mylistPocket-popup .description button {
        /*font-family: Menlo;*/
        font-size: 16px;
        font-weight: bolder;
        margin: 4px 8px;
        padding: 4px 8px;
        cursor: pointer;
        border-radius: 0;
        background: #333;
        color: #ccc;
        border: solid 2px #ccc;
        outline: none;
      }
      #mylistPocket-popup .description button:hover {
        transform: translate(-2px,-2px);
        box-shadow: 2px 2px 2px #000;
        background: #666;
        transition:
          0.2s transform ease,
          0.2s box-shadow ease
          ;
      }
      #mylistPocket-popup .description button:active {
        transform: none;
        box-shadow: none;
        transition: none;
      }
      #mylistPocket-popup .description button:active::hover {
        opacity: 0;
      }

      #mylistPocket-popup .watch {
        display: block;
        position: relative;
        line-height: 60px;
        box-sizing: border-box;
        padding: 4px 16px;;
        min-height: 60px;
        width: 280px;
        margin: 8px 10px;
        background: #444;
        border-radius: 4px;
      }

      #mylistPocket-popup .watch:hover {
        background: #446;
      }

      #mylistPocket-popup .videoThumbnail {
        position: absolute;
        right: 16px;
        height: 60px;
        transform-origin: center;
        transition:
          0.2s transform ease,
          0.2s box-shadow ease
        ;
      }
      #mylistPocket-popup .videoThumbnail:hover {
        transform: scale(2);
        box-shadow: 0 0 8px #888;
        transition:
          0.2s transform ease 0.5s,
          0.2s box-shadow ease 0.5s
        ;
      }


    .zenzaPlayerContainer.is-error   #mylistPocket-popup,
    .zenzaPlayerContainer.is-loading #mylistPocket-popup,
    .zenzaPlayerContainer.error   #mylistPocket-popup,
    .zenzaPlayerContainer.loading #mylistPocket-popup {
      opacity: 0;
      pointer-events: none;
    }

    `).trim();

    const __tpl__ = (`
      <div class="mylistPocketHoverMenu scalingUI">
        <button class="mylistPocketButton command deflist-add wwwZenzaOnly" data-command="deflist"
          tooltip="とりあえずマイリスト">&#x271A;</button>
        <button class="mylistPocketButton command info" data-command="info"
          tooltip="動画情報を表示">?</button>
      </div>

      <div id="mylistPocket-popup">
        <span slot="video-title">【実況】どんぐりころころの大冒険 Part1(最終回)</span>
        <a href="/watch/sm9" slot="watch-link"></a>
        <img slot="video-thumbnail" data-type="image">
        <a slot="owner-page-link" href="//www.nicovideo.jp/user/1234" class="owner-page-link" data-type="link"><img slot="owner-icon" class="owner-icon" src="http://res.nimg.jp/img/user/thumb/blank_s.jpg" data-type="image"></img></a>

        <span slot="upload-date"     data-type="date">1970/01/01 00:00</span>
        <span slot="view-counter"    data-type="int">12,345</span>
        <span slot="mylist-counter"  data-type="int">6,789</span>
        <span slot="comment-counter" data-type="int">2,525</span>

        <span slot="duration" class="duration">1:23</span>

        <span slot="owner-id">1234</span>
        <span slot="locale-owner-name">ほげほげ</span>

        <div slot="error-description"></div>
        <div class="description" slot="description" data-type="html"></div>
        <span slot="last-res-body"></span>

      </div>

      <template id="mylistPocket-popup-template">
        <style>

          :host(#mylistPocket-popup) {
            position: fixed;
            z-index: 200000;
            transform: translate3d(-50%, -50%, 0);
            opacity: 0;
            transition: 0.3s opacity ease;
            top: -9999px; left: -9999px;
          }

          :host(#mylistPocket-popup.show) {
            top: 50%;
            left: 50%;
            opacity: 1;
          }

          .root.is-otherDomain .wwwOnly {
            display: none;
          }
          .root.is-otherDomain:not(.is-zenzaReady) .wwwZenzaOnly {
            display: none;
          }

          * {
            box-sizing: border-box;
          }

          a {
            color: #ffff00;
            font-weight: bold;
            display: inline-block;
          }

          a:visited {
            color: #ffff99;
          }

          button {
            font-size: 14px;
            padding: 8px 8px;
            cursor: pointer;
            border-radius: 0;
            margin: 0;
            background: #333;
            color: #ccc;
            border: solid 2px #ccc;
            outline: none;
            line-height: 20px;
            user-select: none;
            -webkit-user-select: none;
            -moz-user-select: none;
          }
          button:hover {
            transform: translate(-4px,-4px);
            box-shadow: 4px 4px 4px #000;
            background: #666;
            transition:
              0.2s transform ease,
              0.2s box-shadow ease
              ;
          }

          button.is-updating {
            cursor: wait;
          }
          button.is-active,
          button:active {
            transform: none;
            box-shadow: none;
            transition: none;
          }
          button.is-active::after,
          button:active::after {
            opacity: 0;
          }


          [tooltip] {
            position: relative;
          }

          .is-deflistUpdating .deflist-add::after,
          .is-deflistSuccess  .deflist-add::after,
          .is-deflistFail     .deflist-add::after,
          [tooltip]:hover::after {
            content: attr(tooltip);
            position: absolute;
            top:  0px;
            left: 50%;
            padding: 2px 4px;
            white-space: nowrap;
            font-size: 14px;
            color: #fff;
            background: #333;
            transform: translate3d(-50%, -120%, 0);
            pointer-events: none;

          }


          .root {
            text-align: left;

            border-radius: 0px;
            outline-offset: 8px;
            border: 12px solid rgba(32, 32, 32, 0);
            border-radius: 20px;
            padding: 8px 0;
            background: rgba(0, 0, 0, 0.7);
            color: #ccc;
            box-shadow: 0 0 16px #000;
            transition:
              0.6s -webkit-clip-path ease,
              0.6s clip-path ease;
              /*0.4s border-radius ease-out 0.4s,
              0.4s height ease-out 0.4s*/
            ;
          }

          .root.show {
            opacity: 1;
          }

          .root.is-loading,
          .root.is-loading.is-ok,
          .root.is-loading.is-fail {
            text-align: center;
            position: relative;
            width: 190px;
            height: 190px;
            padding: 32px;
            opacity: 0.8;
            cursor: wait;
            border-radius: 100%;
            clip-path: circle(100px at center) !important;
            -webkit-clip-path: circle(100px at center);
            transition: none;
            outline: none;
          }
          .root.is-loading > * {
            pointer-events: none;
          }

          .root.is-loading .loading-inner,
          .root.is-loading.is-ok .loading-inner,
          .root.is-loading.is-fail .loading-inner {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate3d(-50%, -50%, 0);
          }

          .loading-inner .spinner {
            font-size: 64px;
            display: inline-block;
            animation-name: spin;
            animation-iteration-count: infinite;
            animation-duration: 3s;
            animation-timing-function: linear;
          }

          @keyframes spin {
            0%   { transform: rotate(0deg); }
            100% { transform: rotate(1800deg); }
          }



          .root.is-ok {
            width: 800px;
            clip-path: circle(800px at center);
            -webkit-clip-path: circle(800px at center);
          }

          .root.is-ok.noclip {
            clip-path: none;
            -webkit-clip-path: none;
          }

          .root.is-fail {
            font-size: 120%;
            white-space: nowrap;
            text-align: center;
            padding: 16px;
          }

          .root.is-loading>*:not(.loading-now),
          .root.is-loading.is-ok>*:not(.loading-now),
          .root.is-loading.is-fail>*:not(.loading-now),
          .root.is-fail:not(.is-loading)>*:not(.error-info),
          .root.is-ok:not(.is-loading)>*:not(.video-detail) {
            display: none !important;
          }

          .root.is-loading>.loading-now,
          .root.is-fail>.error-info,
          .root.is-ok>.video-detail {
            display: block;
          }

          .header {
            padding: 8px 8px 8px;
            font-size: 12px;
          }
            .upload-date {
              margin-right: 8px;
            }
            .counter span + span {
              margin-left: 8px;
            }
            .video-title {
              font-weight: bolder;
              font-size: 22px;
              margin-bottom: 4px;
            }



          .main {
            display: flex;
            background: rgba(0, 0, 0, 0.2);
            box-shadow: 0 0 4px rgba(0, 0, 0, 0.5) inset;
          }

          .main-left {
            width: 360px;
            padding: 8px;
            z-index: 100;
          }
            .video-thumbnail-container {
              position: relative;
              width: 360px;
              height: 270px;
              background: #000;
              /*box-shadow: 2px 2px 4px #000;*/
            }
            .video-thumbnail-container ::slotted(img) {
              width: 360px;
              height: 270px;
            }

            .video-thumbnail-container .duration {
              position: absolute;
              display: inline-block;
              right: 0;
              bottom: 0;
              font-size: 14px;
              background: #000;
              color: #fff;
              padding: 2px 4px;
            }
            .video-thumbnail-container:hover .duration {
              display: none;
            }


          .main-right {
            position: relative;
            padding: 0;
            flex-grow: 1;
            font-size: 14px;
          }

            ::slotted(.owner-page-link) {
              display: inline-block;
              vertical-align: middle;
            }

            .owner-page-link img {
              border: 1px solid #333;
              border-radius: 3px;
            }

            .video-info {
              /*background: rgba(0, 0, 0, 0.2);*/
              max-height: 282px;
              overflow-x: hidden;
              overflow-y: scroll;
            }

            .video-info::-webkit-scrollbar {
              background: rgba(34, 34, 34, 0.5);
            }

            .video-info::-webkit-scrollbar-thumb {
              border-radius: 0;
              background: #666;
            }

            .video-info::-webkit-scrollbar-button {
              background: #666;
              display: none;
            }

            .video-info::scrollbar {
              background: #222;
            }

            .video-info::scrollbar-thumb {
              border-radius: 0;
              background: #666;
            }

            .video-info::scrollbar-button {
              background: #666;
              display: none;
            }



            .owner-info {
              margin: 16px;
              display: inline-block;
            }

              .owner-info * {
                vertical-align: middle;
              }
              
              .owner-name {
                display: inline-block;
                padding: 8px;
                font-size: 18px;
              }

              .is-channel .owner-name::before {
                content: 'CH';
                margin: 0 4px;
                background: #999;
                color: #333;
                padding: 2px 4px;
                border: 1px solid;
              }

              .locale-owner-name::after {
                content: ' さん';
              }

            .description {
              word-break: break-all;
              line-height: 1.5;
              padding: 0 16px 8px;
            }

            .description:first-letter {
              font-size: 24px;
            }
            
            .last-res-body {
              margin: 16px 16px 0;
              border: 1px solid #ccc;
              padding: 4px;
              border-radius: 4px;
              word-break: break-all;
              font-size: 12px;
              min-height: 24px;
            }


          .footer {
            padding: 8px;
          }

            .pocket-button {
              cusror: pointer;
            }

            .pocket-button:active {
            }


            .video-tags {
              display: block;
            }

              ::slotted(a.tag) {
                font-size: 14px !important;
                display: inline-block !important;
                padding: 4px 8px !important;
                color: #ccc !important;
                text-decoration: none !important;
                border: 1px solid #888 !important;
                border-radius: 4px !important;
                margin: 0 4px 4px 0!important;
                background: rgba(0, 0, 0, 0.2) !important;
              }

              ::slotted(a.tag:hover) {
                color: #fff !important;
              }
            
            .footer-menu {
              position: absolute;
              right: 0px;
              bottom: 0px;
              transform: translate3d(0, 120%, 0);
            }
              .footer-menu button {
                min-width: 70px;
              }
              
              .regular-menu {
                display: inline-block;
                background: rgba(0, 0, 0, 0.7);
                position: relative;
                border-radius: 8px;
                padding: 12px 16px;
                box-shadow: 0 0 16px #000;
              }

              .is-deflistUpdating .deflist-add {
                cursor: wait;
                opacity: 0.9;
                transform: scale(1.0);
                box-shadow: none;
                transition: none;
              }
              .is-deflistSuccess .deflist-add,
              .is-deflistFail    .deflist-add {
                transform: scale(1.0);
                box-shadow: none;
                transition: none;
              }
              .is-deflistSuccess  .deflist-add::after {
                content: attr(data-result);
                background: #393;
              }
              .is-deflistFail     .deflist-add::after {
                content: attr(data-result);
                background: #933;
              }
              .is-deflistUpdating .deflist-add::after {
                content: '更新中';
                background: #333;
              }


              .zenza-menu {
                display: none;
              }

              .is-zenzaReady .zenza-menu {
                display: inline-block;
                background: rgba(0, 0, 0, 0.7);
                /*outline: 4px dashed #69c;
                outline-offset: 4px;*/
                margin-left: 32px;
                position: relative;
                border-radius: 8px;
                padding: 12px 16px;
                box-shadow: 0 0 16px #000;
              }

              .is-zenzaReady .zenza-menu::after {
                content: 'ZenzaWatch';
                position: absolute;
                left: 50%;
                bottom: 10px;
                padding: 2px 8px;
                transform: translate(-50%, 100%);
                pointer-events: none;
                font-weith: bolder;
                background: rgba(0, 0, 0, 0.7);
                pointer-events: none;
                border-radius: 4px;
                white-space: nowrap;
              }

       </style>
        <div class="popup root">
          <div class="loading-now">
            <div class="loading-inner">
              <span class="spinner">&#8987;</span>
            </div>
          </div>
          <div class="error-info">
            <slot name="error-description"></slot>
          </div>
          <div class="video-detail">
            <div class="header">
              <div class="video-title"><slot name="video-title"></slot></div>

              <span class="upload-date">投稿: <slot name="upload-date"/></span>
              <span class="counter">
                <span class="view-counter">再生: <slot name="view-counter"/></span>
                <span class="mylist-counter">マイリスト: <slot name="mylist-counter"/></span>
                <span class="comment-counter">コメント: <slot name="comment-counter"/></span>
              </span>
            </div>

            <div class="main">

              <div class=" main-left">
                <div class="video-thumbnail-container">
                  <slot name="video-thumbnail"></slot>
                  <span class="duration"><slot name="duration"></slot></slot>
                </div>
              </div>

              <div class="video-info main-right">

                <div class="owner-info">
                  <slot name="owner-page-link"></slot>
                  <span class="owner-name"><slot name="locale-owner-name"></slot></span>
                </div>

                <div class="description">
                  <slot name="description"></slot>
                </div>

                <div class="last-res-body">
                  <slot name="last-res-body"></slot>
                </div>

                
              </div>

            </div>

            <div class="footer">
              <div class="video-tags">
                <slot name="tag"></slot>
              </div>
            </div>
            <div class="footer-menu scalingUI">
              <div class="regular-menu">
                <button
                  class="mylistPocketButton deflist-add pocket-button command command-watch-id wwwZenzaOnly"
                  data-command="deflist-add"
                  tooltip="とりあえずマイリスト"
                >とり</button>
                <button
                  class="pocket-button command command-watch-id"
                  data-command="mylist-window"
                  tooltip="マイリスト"
                >マイ</button>
                <button
                  class="pocket-button command command-video-id"
                  data-command="twitter-hash-open"
                  tooltip="Twitterの反応"
                >#Twitter</button>
              </div>


              <div class="zenza-menu">
                <button
                  class="pocket-button command command-watch-id"
                  data-command="zenza-open-now"
                  tooltip="ZenzaWatchで開く"
                >Zen</button>
                <button
                  class="pocket-button command command-watch-id"
                  data-command="playlist-inert"
                  tooltip="プレイリスト(次に再生)"
                >playlist</button>
                <button
                  class="pocket-button command command-watch-id"
                  data-command="playlist-queue"
                  tooltip="プレイリスト(末尾に追加)"
                >queue</button>
              </div>
            </div>
          </div>
        </div>
      </template>
    `).trim();


    // TODO: ライブラリ化
    const util = MylistPocket.util = (() => {
      const util = {};

      util.addStyle = function(styles, id) {
        var elm = document.createElement('style');
        elm.type = 'text/css';
        if (id) { elm.id = id; }

        var text = styles.toString();
        text = document.createTextNode(text);
        elm.appendChild(text);
        var head = document.getElementsByTagName('head');
        head = head[0];
        head.appendChild(elm);
        return elm;
      };

      util.mixin = function(self, o) {
        _.each(Object.keys(o), f => {
          if (!_.isFunction(o[f])) { return; }
          if (_.isFunction(self[f])) { return; }
          self[f] = o[f].bind(o);
        });
      };

      util.createWebWorker = function(func) {
        const src = func.toString().replace(/^function.*?\{/, '').replace(/}$/, '');
        const blob = new Blob([src], {type: 'text\/javascript'});
        const url = URL.createObjectURL(blob);

        return new Worker(url);
      };

      util.attachShadowDom = function({host, tpl, mode = 'open'}) {
        const root = host.attachShadow({mode});
        const node = document.importNode(tpl.content, true);
        root.appendChild(node);
        return root;
      };



      util.getWatchId = function(url) {
        /\/?watch\/([a-z0-9]+)/.test(url || location.pathname);
        return RegExp.$1;
      };

      util.isLogin = function() {
        return document.getElementsByClassName('siteHeaderLogin').length < 1;
      };

      util.escapeHtml = function(text) {
        var map = {
          '&':    '&amp;',
          '\x27': '&#39;',
          '"':    '&quot;',
          '<':    '&lt;',
          '>':    '&gt;'
        };
        return text.replace(/[&"'<>]/g, char => {
          return map[char];
        });
      };

      util.unescapeHtml = function(text) {
        var map = {
          '&amp;'  : '&' ,
          '&#39;'  : '\x27',
          '&quot;' : '"',
          '&lt;'   : '<',
          '&gt;'   : '>'
        };
        return text.replace(/(&amp;|&#39;|&quot;|&lt;|&gt;)/g, char => {
          return map[char];
        });
      };

      util.hasLargeThumbnail = function(videoId) { // return true;
        // 大サムネが存在する最初の動画ID。 ソースはちゆ12歳
        // ※この数字以降でもごく稀に例外はある。
        var threthold = 16371888;
        var cid = videoId.substr(0, 2);
        if (cid !== 'sm') { return false; }

        var fid = videoId.substr(2) * 1;
        if (fid < threthold) { return false; }

        return true;
      };

      util.httpLink = function(html) {
        let links = {}, keyCount = 0;
        const getTmpKey = function() { return ` <!--${keyCount++}--> `; };


        html = html.replace(/@([a-zA-Z0-9_]+)/g,
          (g, id) => {
            const tmpKey = getTmpKey();
            links[tmpKey] =
              ` <a href="https://twitter.com/${id}" class="twitterLink" target="_blank">@${id}</a> `;
            return tmpKey;
          });


        html = html.replace(/(im)(\d+)/g,
          ` <a href="//seiga.nicovideo.jp/seiga/$1$2" class="seigaLink" target="_blank">$1$2</a> `);
        html = html.replace(/(co)(\d+)/g,
          ` <a href="//com.nicovideo.jp/community/$1$2" class="communityLink" target="_blank">$1$2</a> `);
        html = html.replace(/(watch|mylist|user)\/(\d+)/g, ` <a href="/$1/$2" class="videoLink">$1/$2</a> `);
        html = html.replace(/(sm|nm|so)(\d+)/g,       ` <a href="/watch/$1$2" class="videoLink">$1$2</a> `);

        let linkmatch = /<a.*?<\/a>/, n;
        html = html.split('<br />').join(' <br /> ');
        while ((n = linkmatch.exec(html)) !== null) {
          let tmpKey = getTmpKey();
          links[tmpKey] = n;
          html = html.replace(n, tmpKey);
        }

        html = html.replace(/\((https?:\/\/[\x21-\x3b\x3d-\x7e]+)\)/gi, '( $1 )');
        html = html.replace(/(https?:\/\/[\x21-\x3b\x3d-\x7e]+)http/gi, '$1 http');
        html = html.replace(/(https?:\/\/[\x21-\x3b\x3d-\x7e]+)/gi, '<a href="$1" target="_blank" class="otherSite">$1</a>');
        Object.keys(links).forEach(tmpKey => {
          html = html.replace(tmpKey, links[tmpKey]);
        });

        html = html.split(' <br /> ').join('<br />');
        return html;
      };

      util.getSleepPromise = function(sleepTime, label = 'sleep') {
        return function(result) {
          return new Promise(resolve => {
            console.time('sleep promise...' + label);
            window.setTimeout(() => {
              console.timeEnd('sleep promise...' + label);
              return resolve(result);
            }, sleepTime);
          });
        };
      };

      util.getPageLanguage = function() {
        try {
          var h = document.getElementsByClassName('html')[0];
          return h.lang || 'ja-JP';
        } catch(e) {
          return 'ja-JP';
        }
      };


      const videoIdReg = /^[a-z]{2}\d+$/;
      util.getThumbnailUrlByVideoId = function(videoId) {
        if (!videoIdReg.test(videoId)) {
          return null;
        }
        const fileId = parseInt(videoId.substr(2), 10);
        const num = (fileId % 4) + 1;
        const large = util.hasLargeThumbnail(videoId) ? '.L' : '';
        return '//tn-skr' + num + '.smilevideo.jp/smile?i=' + fileId + large;
      };

      return util;
    })();


    class Emitter {
      constructor() {
      }

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

    MylistPocket.emitter = util.emitter = new Emitter();

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

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

          Zenza.emitter.on('hideHover', () => {
            util.emitter.emit('hideHover');
          });

          Zenza.emitter.on('csrfToken', (token) => {
            util.emitter.emit('csrfToken', token);
          });

          let popup = document.getElementById('mylistPocket-popup');
          let defaultContainer = document.getElementById('mylistPocketDomContainer');
          let zenzaContainer;
          Zenza.emitter.on('fullScreenStatusChange', isFull => {
            if (isFull) {
              if (!zenzaContainer) {
                zenzaContainer = document.querySelector('.zenzaPlayerContainer');
              }
              zenzaContainer.appendChild(popup);
            } else {
              defaultContainer.appendChild(popup);
            }
          });
          emitter.emit('ready', Zenza);
        };

        if (window.ZenzaWatch && window.ZenzaWatch.ready) {
          window.console.log('ZenzaWatch is Ready');
          onZenzaReady();
        } else {
          window.jQuery('body').on('ZenzaWatchReady', function() {
          //document.body.addEventListener('ZenzaWatchReady', function() {
            window.console.log('onZenzaWatchReady');
            onZenzaReady();
          });
        }
      };

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

      return {
        initialize: initialize,
        detect: detect
      };

    })();


    const StorageWriter = (function() {
      // マイページのJSON.stringifyがPrototype.jsのせいでぶっこわれているので
      // 汚染されていないWebWorkerを使って書き込む
      const func = function(self) {
        self.onmessage = function(e) {
          const key     = e.data.key;
          const value   = e.data.value;
          const storage = e.data.storage;
          self.postMessage({key, value: JSON.stringify(value), storage});
        };
      };

      const worker = util.createWebWorker(func);
      worker.addEventListener('message', (e) => {
        const key     = e.data.key;
        const value   = e.data.value;
        const storage = e.data.storage === 'session' ? sessionStorage : localStorage;
        storage[key] = value;
      });

      return {
        write: function({key, value, storage = 'local'}) {
          worker.postMessage({
            key,
            value,
            storage
          });
        }
      };
    })();

    MylistPocket.debug.writer = StorageWriter;


    const config = (function() {
      const prefix = PRODUCT + '_config_';
      const emitter = new Emitter();

      const defaultConfig = {
        debug: false
      };

      const config = {};
      let noEmit = false;


      Object.keys(defaultConfig).forEach(key => {
        var storageKey = prefix + key;
        if (localStorage.hasOwnProperty(storageKey)) {
          try {
            config[key] = JSON.parse(localStorage.getItem(storageKey));
          } 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.getValue = function(key, refresh) {
        if (refresh) {
          emitter.refreshValue(key);
        }
        return config[key];
      };

      emitter.setValue = function(key, value) {
        if (config[key] !== value && arguments.length >= 2) {
          var storageKey = prefix + key;
          if (location.host === 'www.nicovideo.jp') {
            StorageWriter.write({key: storageKey, value});
            //localStorage.setItem(storageKey, JSON.stringify(value));
          }
          config[key] = value;

          console.log('%cconfig update "%s" = "%s"', 'background: cyan', key, value);
        }
      };

      emitter.clearConfig = function() {
        noEmit = true;
        Object.keys(defaultConfig).forEach(key => {
          if (_.contains(['message', 'debug'], key)) { return; }
          var storageKey = prefix + key;
          try {
            if (localStorage.hasOwnProperty(storageKey)) {
              localStorage.removeItem(storageKey);
            }
            config[key] = defaultConfig[key];
          } catch (e) {}
        });
        noEmit = false;
      };

      emitter.getKeys = function() {
        return Object.keys(defaultConfig);
      };

      return emitter;
    })();
    MylistPocket.config = config;


    const CacheStorage = (function() {
      var PREFIX = PRODUCT + '_cache_';

      class CacheStorage {

        constructor(storage, gc = false) {
          this._storage = storage;
          this._memory = {};
          if (gc) { this.gc(); }
          Object.keys(storage).forEach((key) => {
            if (key.indexOf(PREFIX) === 0) {
              this._memory[key] = storage[key];
            }
          });
        }

        gc() {
          const storage = this._storage;
          Object.keys(storage).forEach((key) => {
            if (key.indexOf(PREFIX) === 0) {
              let item;
              try {
                item = JSON.parse(this._storage[key]);
              } catch(e) {
                storage.removeItem(key);
              }
              //console.info(
              //  `key: ${key}, expiredAt: ${item.expiredAt}, now: ${Date.now()}`);
              if (item.expiredAt === '' || item.expiredAt > Date.now()) {
                //console.info('not expired: ', key);
                return;
              }
              //console.info('cache expired: ', key, item.expiredAt);
              storage.removeItem(key);
            }
          });
        }

        setItem(key, data, expireTime) {
          key = PREFIX + key;
          const expiredAt =
            typeof expireTime === 'number' ? (Date.now() + expireTime) : '';

          const cacheData = {
            data: data,
            type: typeof data,
            expiredAt: expiredAt
          };

          this._memory[key] = cacheData;
          StorageWriter.write({
            key,
            value: cacheData,
            storage: this._storage === sessionStorage ? 'session' : 'local'
          });
          //this._storage[key] = JSON.stringify(cacheData);
        }

        getItem(key) {
          key = PREFIX + key;
          if (!this._storage.hasOwnProperty(key)) {
            return null;
          }
          let item = null;
          try {
            item = JSON.parse(this._storage[key]);
          } catch(e) {
            delete this._memory[key];
            this._storage.removeItem(key);
            return null;
          }

          if (item.expiredAt === '' || item.expiredAt > Date.now()) {
            return item.data;
          }
          return null;
        }

        removeItem(key) {
          if (this._memory.hasOwnProperty(key)) {
            delete this._memory[key];
          }
          key = PREFIX + key;
          if (this._storage.hasOwnProperty(key)) {
            this._storage.removeItem(key);
          }
        }

        clear() {
          const storage = this._storage;
          this._memory = {};
          Object.keys(storage).forEach((v) => {
            if (v.indexOf(PREFIX) === 0) {
              storage.removeItem(v);
            }
          });
        }
      }
      return CacheStorage;
    })();
    MylistPocket.debug.sessionCache = new CacheStorage(sessionStorage, true);
    MylistPocket.debug.localCache   = new CacheStorage(localStorage, true);

    const WindowMessageEmitter = (function() {
      const emitter = new Emitter();
      const knownSource = [];

      const onMessage = (event) => {
        if (_.indexOf(knownSource, event.source) < 0 //&&
            //event.origin !== location.protocol + '//ext.nicovideo.jp'
            ) { return; }

        try {
          var data = JSON.parse(event.data);
          if (data.id !== PRODUCT) { return; }

          emitter.emit('onMessage', data.body, data.type);
        } catch (e) {
          console.log(
            '%cMylistPocket.Error: window.onMessage  - ',
            'color: red; background: yellow',
            e,
            event
          );
          console.log('%corigin: ', 'background: yellow;', event.origin);
          console.log('%cdata: ',   'background: yellow;', event.data);
          console.trace();
        }
      };

      emitter.addKnownSource = (win) => {
        knownSource.push(win);
      };

      window.addEventListener('message', onMessage);

      return emitter;
    })();


    const CrossDomainGate = (function() {

      class CrossDomainGate extends Emitter {
        constructor(params) {
          super();

          this._baseUrl  = params.baseUrl;
          this._origin   = params.origin || location.href;
          this._type     = params.type;
          this._messager = params.messager || WindowMessageEmitter;

          this._loaderFrame = null;
          this._sessions = {};
          this._initializeStatus = '';
        }

        _initializeFrame() {
          switch (this._initializeStatus) {
            case 'done':
              return new Promise((resolve) => {
                window.setTimeout(() => { resolve(); }, 0);
              });
            case 'initializing':
              return new Promise((resolve, reject) => {
                this.on('initialize', (e) => {
                  if (e.status === 'ok') { resolve(); } else { reject(e); }
                });
              });
            case '':
              this._initializeStatus = 'initializing';
              var initialPromise = new Promise((resolve, reject) => {
                this._sessions.initial = {
                  promise: initialPromise,
                  resolve: resolve,
                  reject: reject
                };

                setTimeout(() => {
                  if (this._initializeStatus !== 'done') {
                    var rej = {
                      status: 'fail',
                      message: 'CrossDomainGate初期化タイムアウト (' + this._type + ')'
                    };
                    reject(rej);
                    this.emit('initialize', rej);
                  }
                }, 60 * 1000);
                this._initializeCrossDomainGate();
              });
              return initialPromise;
          }
        }

        _initializeCrossDomainGate() {
          this._initializeCrossDomainGate = _.noop;
          this._messager.on('onMessage', this._onMessage.bind(this));

          console.log('%c initialize ' + this._type, 'background: lightgreen;');

          const loaderFrame = document.createElement('iframe');

          loaderFrame.name = this._type + 'Loader';
          loaderFrame.className = 'xDomainLoaderFrame ' + this._type;
          document.body.appendChild(loaderFrame);

          this._loaderFrame = loaderFrame;
          this._loaderWindow = loaderFrame.contentWindow;
          this._messager.addKnownSource(this._loaderWindow);
          this._loaderWindow.location.href = this._baseUrl + '#' + TOKEN;
        }

        _onMessage(data, type) {
          if (type !== this._type) {
            return;
          }
          const info      = data.message;
          const token     = info.token;
          const sessionId = info.sessionId;
          const status    = info.status;
          const command   = info.command || 'loadUrl';
          let session   = this._sessions[sessionId];

          if (status === 'initialized') {
            this._initializeStatus = 'done';
            this._sessions.initial.resolve();
            this.emitAsync('initialize', {status: 'ok'});
            return;
          }

          if (token !== TOKEN) {
            window.console.log('invalid token:', token, TOKEN);
            return;
          }

          switch (command) {
            case 'dumpConfig':
              this._onDumpConfig(info.body);
              break;

            default:
              if (!session) { return; }
              if (status === 'ok') { session.resolve(info.body); }
              else { session.reject({ message: status }); }
              session = null;
              delete this._sessions[sessionId];
              break;
          }
        }

        load(url, options) {
          return this._postMessage({
            command: 'loadUrl',
            url: url,
            options: options
          }, true);
        }

        _postMessage(message, needPromise) {
          return new Promise((resolve, reject) => {
            message.sessionId = this._type + '_' + Math.random();
            message.token = TOKEN;
            if (needPromise) {
              this._sessions[message.sessionId] = {
                resolve: resolve,
                reject: reject
              };
            }

            return this._initializeFrame().then(() => {
              try {
                this._loaderWindow.postMessage(
                  JSON.stringify(message),
                  this._origin
                );
              } catch (e) {
                console.log('%cException!', 'background: red;', e);
              }
            });
          });
        }


      }

      return CrossDomainGate;
    })();



    const CsrfTokenLoader = (() => {
      const cacheStorage = new CacheStorage(
        location.host === 'www.nicovideo.jp' ? localStorage : sessionStorage);
      const TIMEOUT = 10 * 1000;
      const CACHE_EXPIRE_TIME = 60 * 30 * 1000;

      class CsrfTokenLoader {
        static load() {
          return new Promise((resolve, reject) => {
            const cache = cacheStorage.getItem('csrfToken');
            if (cacheStorage.getItem('csrfToken')) {
              return resolve(cache);
            }

            let timeoutTimer = window.setTimeout(() => {
              reject('timeout');
            }, TIMEOUT);

            return CsrfTokenLoader._getToken().then((token) => {
              window.clearTimeout(timeoutTimer);
              CsrfTokenLoader.saveToCache(token);
              resolve(token);
            });
          });
        }

        static saveToCache(token) {
          cacheStorage.setItem('csrfToken', token, CACHE_EXPIRE_TIME);
        }

        static _getToken() {
          const url = 'http://www.nicovideo.jp/mylist_add/video/sm9';
          const tokenReg = /NicoAPI\.token *= *["']([a-z0-9\-]+)["'];/;

          return fetch(url, {
            credentials: 'include'
          }).then((res) => {
            return res.text();
          }).then((result) => {
            if (tokenReg.test(result)) {
              let token = RegExp.$1;
              return Promise.resolve(token);
            } else {
              return Promise.reject('token parse error');
            }
          });
        }
      }

      util.emitter.on('csrfToken', (token) => {
        CsrfTokenLoader.saveToCache(token);
      });

      return CsrfTokenLoader;
    })();

    MylistPocket.debug.CsrfTokenLoader = CsrfTokenLoader;

    const ThumbInfoLoader = (() => {
      const BASE_URL = location.protocol + '//ext.nicovideo.jp/';
      const MESSAGE_ORIGIN = location.protocol + '//ext.nicovideo.jp/';
      let gate = null;
      let cacheStorage = new CacheStorage(localStorage);

      class ThumbInfoLoader {

        constructor() {
          this._emitter = new Emitter();

          gate = new CrossDomainGate({
            baseUrl: BASE_URL,
            origin: MESSAGE_ORIGIN,
            type: 'thumbInfo' + PRODUCT,
            messager: WindowMessageEmitter
          });
        }

        _onMessage(data, type) {
          if (type !== 'videoInfoLoader') { return; }
          const info = data.message;

          this.emit('load', info, 'THUMB_WATCH');
        }

        _parseXml(xmlText) {
          const parser = new DOMParser();
          const xml = parser.parseFromString(xmlText, 'text/xml');
          const val = (name) => {
            var elms = xml.getElementsByTagName(name);
            if (elms.length < 1) {
              return null;
            }
            return elms[0].innerHTML;
          };

          const resp = xml.getElementsByTagName('nicovideo_thumb_response');
          if (resp.length < 1 || resp[0].getAttribute('status') !== 'ok') {
            return {
              status: 'fail',
              code: val('code'),
              message: val('description')
            };
          }

          const duration = (() => {
            const tmp = val('length').split(':');
            return parseInt(tmp[0], 10) * 60 + parseInt(tmp[1], 10);
          })();
          const watchId = val('watch_url').split('/').reverse()[0];
          const postedAt = (new Date(val('first_retrieve'))).toLocaleString();
          const tags = (() => {
            const result = [], t = xml.getElementsByTagName('tag');
            _.each(t, (tag) => {
              result.push(tag.innerHTML);
            });
            return result;
          })();

          const result = {
            status: 'ok',
            _format:     'thumbInfo',
            v:            watchId,
            id:           val('video_id'),
            title:        val('title'),
            description:  val('description'),
            thumbnail:    val('thumbnail_url'),
            movieType:    val('movie_type'),
            lastResBody:  val('last_res_body'),
            duration:     duration,
            postedAt:     postedAt,
            mylistCount:  parseInt(val('mylist_counter'), 10),
            viewCount:    parseInt(val('view_counter'), 10),
            commentCount: parseInt(val('comment_num'), 10),
            tagList: tags
          };
          const userId = val('user_id');
          if (userId !== null) {
            result.owner = {
              type: 'user',
              id: userId,
              name: val('user_nickname') || '(非公開ユーザー)',
              url:  userId ? ('//www.nicovideo.jp/user/' + userId) : '#',
              icon: val('user_icon_url') || '//res.nimg.jp/img/user/thumb/blank.jpg'
            };
          }
          const channelId  = val('ch_id');
          if (channelId !== null) {
            result.owner = {
              type: 'channel',
              id: channelId,
              name: val('ch_name') || '(非公開ユーザー)',
              url: '//ch.nicovideo.jp/ch' + channelId,
              icon: val('ch_icon_url') || '//res.nimg.jp/img/user/thumb/blank.jpg'
            };
          }

          return result;
        }

        loadXml(watchId) {
          return this.load(watchId, 'xml');
        }

        load(watchId, format) {
          return new Promise((resolve, reject) => {
            const cache = cacheStorage.getItem('thumbInfo_' + watchId);

            const onLoad = (xml) => {
              const result = this._parseXml(xml);
              if (result.status === 'ok') {
                if (!cache) {
                  cacheStorage.setItem('thumbInfo_' + watchId, xml, 60 * 60 * 1000);
                }
                resolve({data: format === 'xml' ? xml : result, watchId});
              } else {
                reject({data: format === 'xml' ? xml : result, watchId});
              }
            };

            if (cache) {
              //console.log('cache exist: ', watchId);
              window.setTimeout(() => { onLoad(cache); }, 0);
              return;
            }

            gate.load(BASE_URL + 'api/getthumbinfo/' + watchId).then(onLoad);
          });
        }
      }

      const loader = new ThumbInfoLoader();
      return {
        load:    (watchId) => { return loader.load(watchId); },
        loadXml: (watchId) => { return loader.loadXml(watchId); },
        loadOwnerInfo: (watchId) => {
          return loader.load(watchId).then((info) => {
            const owner = info.data.owner;
            if (!owner) {
              return {};
            }

            const lang = util.getPageLanguage();
            const prefix = owner.type === 'user' ? '投稿者: ' : '提供: ';
            const suffix =
              (owner.type === 'user' && lang === 'ja-JP') ? ' さん' : '';
            owner.localeName = `${prefix}${owner.name}${suffix}`;
            return owner;
          });
        }
      };

    })();

    MylistPocket.debug.ThumbInfoLoader = ThumbInfoLoader;



    const DeflistApiLoader = ((CsrfTokenLoader) => {
      const cacheStorage = new CacheStorage(
        location.host === 'www.nicovideo.jp' ? localStorage : sessionStorage);
      const TIMEOUT = 30000;
      const CACHE_EXPIRE_TIME = 60 * 3 * 1000;
      let isZenzaReady = false;

      class DeflistApiLoader {

        static getItems() {
          const url = '//www.nicovideo.jp/api/deflist/list';
          const cacheKey = 'deflistItems';

          return new Promise(function(resolve, reject) {

            const cache = cacheStorage.getItem(cacheKey);
            if (cache) {
              window.setTimeout(() => {
                resolve({items: cache.mylistitem, status: cache.status, from: 'cache'});
              }, 0);
              return;
            }

            let timeoutTimer = window.setTimeout(() => {
              timeoutTimer = null;
              reject({status: 'fail', description: 'timeout'});
            }, TIMEOUT);

            fetch(url, {
              credentials: 'include'
            }).then((res) => {
              return res.json();
            }).then((json) => {
              if (json.status !== 'ok') {
                return reject(json);
              }

              if (timeoutTimer) { window.clearTimeout(timeoutTimer);
              } else { return; }

              cacheStorage.setItem(cacheKey, json, CACHE_EXPIRE_TIME);
              resolve({items: json.mylistitem, status: json.status, from: 'fetch'});
            });
          });
        }

        static findItemByWatchId(watchId) {
          return DeflistApiLoader.getItems().then(({items}) => {
            for (var i = 0, len = items.length; i < len; i++) {
              var item = items[i], wid = item.id || item.item_data.watch_id;
              if (wid === watchId) {
                return Promise.resolve(item);
              }
            }
            return Promise.reject();
          });
        }

        static _removeItem({watchId, token}) {
          const cacheKey = 'deflistItems';
          DeflistApiLoader.findItemByWatchId(watchId).then((item) => {
          const url = '//www.nicovideo.jp/api/deflist/delete';
          const body = 'id_list[0][]=' + item.item_id + '&token=' + token;

          const req = {
            credentials: 'include',
            method: 'post',
            body,
            headers: {'Content-Type': 'application/x-www-form-urlencoded'}
          };

          return fetch(url, req)
            .then(res => { return res.json(); })
            .then((result) => {
              if (result.status !== 'ok') {
                return Promise.reject({
                  status: 'fail',
                  result: result,
                  code: result.error.code,
                  message: result.error.description
                });
              }


              cacheStorage.removeItem(cacheKey);
              util.emitter.emitAsync('deflistRemove', watchId);
              return Promise.resolve({
                status: 'ok',
                result: result,
                message: 'とりあえずマイリストから削除'
              });

            }, (err) => {
              return Promise.reject({
                result: err,
                message: 'とりあえずマイリストから削除失敗(2)'
              });
            });

          }, (err) => {
            return Promise.reject({
              status: 'fail',
              result: err,
              message: '動画が見つかりません'
            });
          });
        }

        static removeItem(watchId) {
          return CsrfTokenLoader.load().then((token) => {
            return DeflistApiLoader._removeItem({watchId, token});
          });
        }

        static __addItem({watchId, description, token, isRetry = false}) {
          const cacheKey = 'deflistItems';
          const url = '//www.nicovideo.jp/api/deflist/add';
          let body = 'item_id=' + watchId + '&token=' + token;
          if (description) {
            body += '&description='+ encodeURIComponent(description);
          }

          const req = {
            method: 'post',
            credentials: 'include',
            body,
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          };

          return new Promise((resolve, reject) => {
            fetch(url, req)
              .then((res) => { return res.json(); })
              .then((result) => {

              if (result.status && result.status === 'ok') {
                cacheStorage.removeItem(cacheKey);
                //ZenzaWatch.emitter.emitAsync('deflistAdd', watchId, description);
                return resolve({
                  status: 'ok',
                  result: result,
                  message: 'とりあえずマイリスト登録'
                });
              }

              if (!result.status || !result.error) {
                return reject({
                  status: 'fail',
                  result: result,
                  message: 'とりあえずマイリスト登録失敗(100)'
                });
              }

              if (result.error.code !== 'EXIST' || isRetry) {
                return reject({
                  status: 'fail',
                  result: result,
                  code: result.error.code,
                  message: result.error.description
                });
              }

              /**
              * すでに登録されている場合は、いったん削除して再度追加(先頭に移動)
              */
              return DeflistApiLoader.removeItem(watchId)
                .then(util.getSleepPromise(1500, 'deflist remove'))
                .then(() => {
                return DeflistApiLoader._addItem(watchId, description, true)
                  .then((result) => {
                    resolve({
                      status: 'ok',
                      result: result,
                      message: 'とりあえずマイリストの先頭に移動'
                    });
                });
              }, (err) => {

                reject({
                  status: 'fail',
                  result: err.result,
                  code:   err.code,
                  message: 'とりあえずマイリスト登録失敗(101)'
                });
              });

            }, (err) => {
              reject({
                status: 'fail',
                result: err,
                message: 'とりあえずマイリスト登録失敗(200)'
              });
            });
          });
        }

        static _addItem(watchId, description, isRetry = false) {
          return CsrfTokenLoader.load().then((token) => {
            return DeflistApiLoader.__addItem({watchId, description, isRetry, token});
          });
        }

        static addItem(watchId, description) {
          return DeflistApiLoader._addItem(watchId, description, false);
        }

        static addItemWithOwnerName(watchId) {
          return ThumbInfoLoader.loadOwnerInfo(watchId).then((owner) => {
            if (!owner.id) {
              return DeflistApiLoader.addItem(watchId);
            }

            const description = owner.localeName;
            return DeflistApiLoader.addItem(watchId, description);
          }, () => {
            return DeflistApiLoader.addItem(watchId);
          });
          //  .then(
          //    (result) => { console.log('ok', result); },
          //    (err)    => { console.error('err', err); }
          //);
        }


        static clearCache() {
          cacheStorage.removeItem('deflistItems');
        }

      }

      ZenzaDetector.detect().then((ZenzaWatch) => {
        isZenzaReady = true;
        ZenzaWatch.emitter.on('deflistRemove', () => {
          DeflistApiLoader.clearCache();
        });
      });

      //DeflistApiLoader.clearCache();

      return DeflistApiLoader;
    })(CsrfTokenLoader);

    MylistPocket.debug.DeflistApiLoader = DeflistApiLoader;

    class HoverMenu extends Emitter {
      constructor() {
        super();
        this._init();
      }
      
      _init() {
        this._view = document.querySelector('.mylistPocketHoverMenu');

        this._view.addEventListener('click', this._onClick.bind(this));

        $('body')
          .on('mouseover', 'a[href*="watch/"],a[href*="nico.ms/"]',
              this._onHover.bind(this))
          .on('mouseover', 'a[href*="watch/"],a[href*="nico.ms/"]',
              _.debounce(this._onHoverEnd.bind(this), 500))
          .on('mouseout',  'a[href*="watch/"],a[href*="nico.ms/"]',
              this._onMouseout.bind(this))
          .on('click', () => { this.hide(); });

        util.emitter.on('hideHover', () => {
          this.hide();
        });

        this._x = this._y = 0;

        ZenzaDetector.detect().then((ZenzaWatch) => {
          this._isZenzaReady = true;
          this.addClass('is-zenzaReady');
          ZenzaWatch.emitter.on('DialogPlayerOpen', _.debounce(() => {
            this.hide();
          }, 1000));
        });

        this.toggleClass('is-otherDomain', location.host !== 'www.nicovideo.jp');
        this._deflistButton = this._view.querySelector('.mylistPocketButton.deflist-add');
        MylistPocket.debug.hoverMenu = this._view;
      }

      toggleClass(className, v) {
        className.split(/ +/).forEach((c) => {
          this._view.classList.toggle(c, v);
        });
      }

      addClass(className)    { this.toggleClass(className, true); }
      removeClass(className) { this.toggleClass(className, false); }

      hide() {
        this.removeClass('is-show');
      }

      show() {
        this.addClass('is-show');
      }

      moveTo(x, y) {
        this._x = x;
        this._y = y;
        this._view.style.left = x + 'px';
        this._view.style.top  = y + 'px';
      }

      _onClick(e) {
        const watchId = this._watchId;
        const target = e.target.classList.contains('command') ?
          e.target : e.target.closest('.command');
        const command = target.getAttribute('data-command');
        e.preventDefault();
        e.stopPropagation();

        if (command === 'info') {
          this._videoInfo(watchId);
          this.hide();
        } else {
          this._deflist(watchId);
        }
      }

      _videoInfo(watchId) {
        this.emit('info', watchId || this._watchId, this);
      }

      _deflist(watchId) {
        this.emit('deflist-add', watchId || this._watchId, this);
      }

      _onHover(e) {
        this._hoverElement = e.target;
      }

      _onHoverEnd(e) {
        if (this._hoverElement !== e.target) { return; }
        const target = e.target.closest('a');
        const $target = $(target);
        const href = target.getAttribute('data-href') || target.getAttribute('href');
        const watchId = util.getWatchId(href);
        const offset = $target.offset();
        const host = target.hostname;
        //console.info('onHoverEnd target=%s, href=%s, target=%s, href=%s, watchId=%s, host=%s', target, href, watchId, host, offset);
        if (host !== 'www.nicovideo.jp' && host !== 'nico.ms') { return; }
        //this._query = util.parseQuery(($target[0].search || '').substr(1));

        if ($target.hasClass('noHoverMenu')) { return; }
        if (!watchId.match(/^[a-z0-9]+$/)) { return; }
        if (watchId.indexOf('lv') === 0) { return; }

        this._watchId = watchId;
        
        this.show();
        this.moveTo(
          offset.left + target.offsetWidth  - this._view.offsetWidth / 2,
          offset.top  + target.offsetHeight / 2 - this._view.offsetHeight / 2
        );
      }

      _onMouseout(e) {
        if (this._hoverElement === e.target) {
          this._hoverElement = null;
        }
      }

      set isBusy(v) {
        this._isBusy = v;
        this.toggleClass('is-busy', v);
      }

      get isBusy() {
        return !!this._isBusy;
      }

      notifyBeginDeflistUpdate(/*watchId*/) {
        this.addClass('is-deflistUpdating');
      }

      notifyEndDeflistUpdate(result) {
        this.addClass('is-deflistSuccess');
        window.setTimeout(() => { this.removeClass('is-deflistSuccess'); }, 3000);

        //window.console.info('ok result', result);
        this._deflistButton.setAttribute('data-result', result.message || '登録しました');
        this.removeClass('is-deflistUpdating');
      }

      notifyFailDeflistUpdate(result) {
        this.addClass('is-deflistFail');
        window.setTimeout(() => { this.removeClass('is-deflistFail'); }, 3000);

        //window.console.info('fail result', result);
        this._deflistButton.setAttribute('data-result', result.message || '登録失敗');
        this.removeClass('is-deflistUpdating');
      }
    }


    class VideoInfoView extends Emitter {
      constructor({host, tpl}) {
        super();
        this._host = host;
        this._tpl = tpl;
        this._slot = {};

      }

      _initialize() {
        if (this._isInitialized) { return; }
        const host = this._host;
        const tpl = this._tpl;

        this._shadowRoot = util.attachShadowDom({host, tpl});
        this._host.querySelectorAll('*').forEach((elm) => {
          const slot = elm.getAttribute('slot');
          if (!slot) { return; }
          //const type = elm.getAttribute('data-type') || 'string';
          this._slot[slot] = elm;
        });

        this._rootDom = this._shadowRoot.querySelector('.root');
        this._hostDom = this._host;

        this._rootDom.addEventListener('mousedown', (e) => {
          e.stopPropagation();
        });
        //this._rootDom.querySelector('.description').addEventListener('mousewheel', (e) => {
        //  e.preventDefault();
        //});
        this._rootDom.addEventListener('click', this._onClick.bind(this));

        this._boundOnBodyMouseDown = this._onBodyMouseDown.bind(this);

        MylistPocket.debug.view = this;

        util.emitter.on('hideHover', () => {
          this.hide();
        });

        ZenzaDetector.detect().then(() => {
          this._isZenzaReady = true;
          this.addClass('is-zenzaReady');
          window.ZenzaWatch.emitter.on('DialogPlayerOpen', _.debounce(() => {
            this.hide();
          }, 1000));
        });
  
        this._videoInfoArea = this._rootDom.querySelector('.video-info');
        this._deflistButton =
          this._rootDom.querySelector('.mylistPocketButton.deflist-add');

        this.toggleClass('is-otherDomain', location.host !== 'www.nicovideo.jp');
        this._isInitialized = true;
      }

      toggleClass(className, v) {
        className.split(/ +/).forEach((c) => {
          this._rootDom.classList.toggle(c, v);
          this._hostDom.classList.toggle(c, v);
        });
      }

      addClass(className)    { this.toggleClass(className, true); }
      removeClass(className) { this.toggleClass(className, false); }
      
      bind(videoInfo) {
        //console.info('status?', videoInfo.status, videoInfo.status === 'ok');
        if (videoInfo.status === 'ok') {
          this._bindSuccess(videoInfo);
        } else {
          this._bindFail(videoInfo);
        }
        window.setTimeout(() => {
          this.removeClass('is-loading');
        }, 0);
      }

      _onClick(e) {
        const t = e.target;
        const elm =
          t.classList.contains('command') ?
            t : e.target.closest('.command');
        if (!elm) { return; }

        // 簡易 throttle
        if (elm.classList.contains('is-active')) { return; }
        elm.classList.add('is-active');
        window.setTimeout(() => { elm.classList.remove('is-active'); }, 500);

        e.preventDefault();
        e.stopPropagation();
        const command = elm.getAttribute('data-command');
        const param   = elm.getAttribute('data-param');
        this.emit('command', command, param, this);
      }


      _onBodyMouseDown() {
        document.body.removeEventListener('mousedown', this._boundOnBodyMouseDown);
        this.hide();
      }

      reset() {
        this._initialize();
        this._videoInfoArea.scrollTop = 0;
        this.removeClass('noclip');
        this.addClass('is-loading');
      }

      show() {
        this.addClass('show');
        document.body.addEventListener('mousedown', this._boundOnBodyMouseDown);
      }

      hide() {
        this.removeClass('show is-ok is-fail noclip');
      }
      
      _bindSuccess(videoInfo) {
        const toCamel = p => {
          return p.replace(/-./g, s => { return s.charAt(1).toUpperCase(); });
        };

        Object.keys(this._slot).forEach((key) => {
          const camelKey = toCamel(key);
          const data = videoInfo[camelKey];
          //console.log('keys', typeof data, key, camelKey, data);
          if (typeof data !== 'string' && typeof data !== 'object') { return; }

          const elm = this._slot[key];
          const type = elm.getAttribute('data-type') || 'string';

          switch (type) {
            case 'html':
              this._createDescription(elm, data);
              break;
            case 'int':
              let i = parseInt(data, 10);
              i = i.toLocaleString ? i.toLocaleString() : i;
              elm.textContent = i;
              break;
            case 'link':
              elm.href = data;
              break;
            case 'image':
              elm.src = data;
              break;
            case 'date':
              elm.textContent = data.toLocaleString();
              break;
            default:
              elm.textContent = data;
          }
        });

        const df = document.createDocumentFragment();
        this._host.querySelectorAll('.tag').forEach(t => { t.remove(); });
        videoInfo.tags.forEach(tag => {
          df.appendChild((this._createTagSlot(tag)));
        });
        this._host.appendChild(df);

        this._rootDom.querySelectorAll('.command-watch-id').forEach(elm => {
          elm.setAttribute('data-param', videoInfo.watchId);
        });
        this._rootDom.querySelectorAll('.command-video-id').forEach(elm => {
          elm.setAttribute('data-param', videoInfo.videoId);
        });

        this.toggleClass('is-channel', videoInfo.isChannel);
        this.addClass('is-ok');
        this.removeClass('is-fail');
        window.setTimeout(() => { this.addClass('noclip'); }, 1000);
      }

      _createDescription(elm, data) {
        elm.innerHTML = util.httpLink(data);
        const watchReg = /watch\/([a-z0-9]+)/;
        const isZenzaReady = this._isZenzaReady;
        elm.querySelectorAll('.videoLink[href*=\'watch/\']').forEach((link) => {
          const href = link.getAttribute('href');
          if (!watchReg.test(href)) { return; }
          const watchId = RegExp.$1;
          if (isZenzaReady) {
            link.classList.add('noHoverMenu');
            link.classList.add('command');
            link.setAttribute('data-command', 'zenza-open');
            link.setAttribute('data-param', watchId);
          }
          const btn = document.createElement('button');
          btn.innerHTML = '?';
          btn.className = 'command command-button noHoverMenu';
          btn.setAttribute('slot', 'command-button');
          btn.setAttribute('tooltip', '動画情報');
          btn.setAttribute('data-command', 'info');
          btn.setAttribute('data-param', watchId);
          link.appendChild(btn);

          const thumbnail = util.getThumbnailUrlByVideoId(watchId);
          if (thumbnail) {
            const img = document.createElement('img');
            img.className = 'videoThumbnail';
            img.src = thumbnail;
            link.classList.add('popupThumbnail');
            link.appendChild(img);
          }
          link.classList.add('watch');
        });
      }

      _bindFail(videoInfo) {
        this._slot['error-description'].textContent =
          `動画情報の取得に失敗しました (${videoInfo.description})`;
        this.addClass('is-fail');
        this.removeClass('is-ok');
      }


      _createTagSlot(tag) {
        const text = util.escapeHtml(tag.text);
        const lock = tag.isLocked ? 'is-locked' : '';
        const a = document.createElement('a');
        a.textContent = tag.text;
        a.slot      = 'tag';
        a.className = `tag ${lock}`;
        a.href      = `/tag/${encodeURIComponent(text)}`;
        return a;
      }

      notifyBeginDeflistUpdate(/*watchId*/) {
        this.addClass('is-deflistUpdating');
      }

      notifyEndDeflistUpdate(result) {
        this.addClass('is-deflistSuccess');
        window.setTimeout(() => { this.removeClass('is-deflistSuccess'); }, 3000);

        //window.console.info('ok result', result);
        this._deflistButton.setAttribute('data-result', result.message || '登録しました');
        this.removeClass('is-deflistUpdating');
      }

      notifyFailDeflistUpdate(result) {
        this.addClass('is-deflistFail');
        window.setTimeout(() => { this.removeClass('is-deflistFail'); }, 3000);

        //window.console.info('fail result', result);
        this._deflistButton.setAttribute('data-result', result.message || '登録失敗');
        this.removeClass('is-deflistUpdating');
      }
    }


    class VideoInfo {
      static createByThumbInfo({xml, watchId}) {
        const dom = (new DOMParser()).parseFromString(xml, 'text/xml');
        const status =
          dom.getElementsByTagName('nicovideo_thumb_response')[0].getAttribute('status');
        //console.info('status', status);
        const t = function(name) {
          const tt = dom.getElementsByTagName(name);
          if (!tt || !tt[0]) {
            return '';
          }
          return tt[0].textContent.trim();
        };

        const videoId = t('video_id');
        let thumbnail = t('thumbnail_url');
        if (util.hasLargeThumbnail(videoId)) {
          thumbnail += '.L';
        }

        const isChannel = !!t('ch_id');
        const tags = [];
        const rawData = {
          status,
          videoId:        t('video_id'),
          watchId:        watchId,
          videoTitle:     t('title'),
          videoThumbnail: thumbnail,
          uploadDate:     t('first_retrieve'),
          duration:       t('length'),
          viewCounter:    t('view_counter'),
          mylistCounter:  t('mylist_counter'),
          commentCounter: t('comment_num'),
          description:    t('description'),
          lastResBody:    t('last_res_body'),
          isChannel,
          ownerId:   isChannel ? t('ch_id')       : t('user_id'),
          ownerName: isChannel ? t('ch_name')     : t('user_nickname'),
          ownerIcon: isChannel ? t('ch_icon_url') : t('user_icon_url'),
          tags
        };

        dom.querySelectorAll('tag').forEach(tag => {
          const isLocked = tag.getAttribute('lock');
          const text = tag.textContent;
          tags.push({text, isLocked});
        });

        return new VideoInfo(rawData);
      }

      constructor(rawData) {
        this._rawData = rawData;
      }

      get status()           { return this._rawData.status; }
      get videoId()          { return this._rawData.videoId; }
      get watchId()          { return this._rawData.watchId; }
      get videoTitle()       { return this._rawData.videoTitle; }
      get videoThumbnail()   { return this._rawData.videoThumbnail; }
      get description()      { return this._rawData.description; }
      get duration()         { return this._rawData.duration; }

      get ownerPageLink()  {
        const ownerId = this.ownerId;
        if (this.isChannel) {
          return `//ch.nicovideo.jp/ch${ownerId}`;
        } else {
          return `//www.nicovideo.jp/user/${ownerId}`;
        }
      }
      get ownerIcon()      { return this._rawData.ownerIcon; }
      get ownerName()      { return this._rawData.ownerName; }
      get localeOwnerName() {
        if (this.isChannel) {
          return this.ownerName;
        } else {
          // TODO: 言語依存
          return this.ownerName + ' さん';
        }
      }
      get ownerId()        { return this._rawData.ownerId; }
      get isChannel()      { return this._rawData.isChannel; }
      get uploadDate()     { return new Date(this._rawData.uploadDate); }

      get viewCounter()    { return this._rawData.viewCounter; }
      get mylistCounter()  { return this._rawData.mylistCounter; }
      get commentCounter() { return this._rawData.commentCounter; }

      get lastResBody()    { return this._rawData.lastResBody; }
      get tags() { return this._rawData.tags; }
    }



    const deflistAdd = (watchId) => {
      if (location.host === 'www.nicovideo.jp') {
        return DeflistApiLoader.addItemWithOwnerName(watchId);
      } else {
          let zenza;
          let token;
          return ZenzaDetector.detect().then((z) => {
            zenza = z;
          }).then(() => {
            return CsrfTokenLoader.load().then((t) => {
              token = t;
            }, () => { return Promise.resolve(); });
          }).then(() => {
            return ThumbInfoLoader.loadOwnerInfo(watchId);
          }).then((owner) => {
            //console.info(watchId, token, owner, zenza);
            if (!owner.id) {
              return zenza.external.deflistAdd(watchId);
            }

            const description = owner.localeName;
            return zenza.external.deflistAdd({watchId, description, token});
          });
      }
    };





    const initDom = () => {
      util.addStyle(__css__);
      const f = document.createElement('div');
      f.id = 'mylistPocketDomContainer';
      f.innerHTML = __tpl__;
      document.body.appendChild(f);
    };

    const initZenzaBridge = () => {
      ZenzaDetector.initialize();
    };

    const createVideoInfoView = () => {
      const host = document.getElementById('mylistPocket-popup');
      const tpl  = document.getElementById('mylistPocket-popup-template');
      const vv = new VideoInfoView({host, tpl});
      return vv;
    };

    const createVideoInfoLoader = (vv) => {

      const onVideoInfoLoad = ({data, watchId}) => {
        const vi = VideoInfo.createByThumbInfo({xml: data, watchId});
        vv.bind(vi);
      };

      const onVideoInfoFail = () => {
        vv.bind({status: 'fail', description: '通信失敗'});
        return Promise.resolve();
      };

      return function(watchId) {
        vv.reset();
        vv.show();
        return ThumbInfoLoader.loadXml(watchId).then(
          onVideoInfoLoad, onVideoInfoFail
        );
      };
    };

    const createCommandDispatcher = ({infoView}) => {
      const load = createVideoInfoLoader(infoView);

      return (command, param, src) => {
        switch(command) {
          case 'info':
            return load(param);
          case 'mylist-window':
            window.open(
             '//www.nicovideo.jp/mylist_add/video/' + param,
             'nicomylistadd',
             'width=500, height=400, menubar=no, scrollbars=no');
            break;
          case 'twitter-hash-open':
            window.open('https://twitter.com/hashtag/' + param + '?src=hash');
            break;
          case 'zenza-open-now':
            window.ZenzaWatch.external.sendOrExecCommand('openNow', param);
            break;
          case 'zenza-open':
            window.ZenzaWatch.external.sendOrOpen(param);
            break;
          case 'playlist-inert':
            window.ZenzaWatch.external.playlist.insert(param);
            break;
          case 'playlist-queue':
            window.ZenzaWatch.external.playlist.add(param);
            break;
          case 'deflist-add':
            src.notifyBeginDeflistUpdate('is-deflistUpdating');

            return deflistAdd(param)
              .then(util.getSleepPromise(1000, 'deflist-add'))
              .then((result) => {
                //console.info('deflist-add-result', result);
                //src.removeClass('is-deflistUpdating');
                src.notifyEndDeflistUpdate(result);
              }, (err) => {
                console.error('deflist-add-result', err);
                src.notifyFailDeflistUpdate(err);
              });
        }
      };
    };

    const initExternal = (dispatcher, hoverMenu, infoView) => {
      MylistPocket.external = {
        info: (watchId) => { dispatcher('info', watchId); },
        hide: () => {
          hoverMenu.hide();
          infoView.hide();
        }
      };

      MylistPocket.isReady = true;
      $('body').trigger('MylistPocketReady', MylistPocket);
    };

    const init = () => {
      initDom();
      initZenzaBridge();

      const infoView = createVideoInfoView();
      const dispatcher = createCommandDispatcher({infoView});

      infoView.on('command', dispatcher);

      const hoverMenu = new HoverMenu();
      hoverMenu.on('info', (watchId) => {
        hoverMenu.isBusy = true;

        dispatcher('info', watchId)
          .then(() => { hoverMenu.isBusy = false; });
      });
      hoverMenu.on('deflist-add', (watchId, src) => {
        dispatcher('deflist-add', watchId, src);
      });
      MylistPocket.debug.hoverMenu = hoverMenu;


      initExternal(dispatcher, hoverMenu, infoView);
    };

    init();
  };

  const postToParent = function(type, message, token) {
    const origin = document.referrer;
    //console.info('postToParent type=%s, message=%s, token=%s, origin=%s',
    //  type, message, token, origin);
    try {
      parent.postMessage(JSON.stringify({
          id: PRODUCT,
          type: type,
          body: {
            token: token,
            url: location.href,
            message: message
          }
        }),
        origin);
    } catch (e) {
      alert(e);
      console.log('err', e);
    }
  };


  const thumbInfoApi = function() {
    if (window.name.indexOf('thumbInfo' + PRODUCT + 'Loader') < 0 ) { return; }
    window.console.log(
      '%cCrossDomainGate: %s %s',
      'background: lightgreen;',
      PRODUCT,
      location.host);

    const parentHost = document.referrer.split('/')[2];
    if (!parentHost.match(/^[a-z0-9]*.nicovideo.jp$/)) {
      window.console.log('disable bridge');
      return;
    }

    const type = 'thumbInfo' + PRODUCT;
    const token = location.hash ? location.hash.substr(1) : null;
    location.hash = '';

    window.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      let timeoutTimer, isTimeout = false;

      if (data.token !== token) { return; }
      //window.console.log('child onMessage', data, event);


      if (!data.url) { return; }
      const sessionId = data.sessionId;
      fetch(data.url).then((resp) => {
        return resp.text();
      }).then((text) => {
        if (isTimeout) { return; }
        else { window.clearTimeout(timeoutTimer); }

        try {
          postToParent(type, {
            sessionId: sessionId,
            status: 'ok',
            token: token,
            url: data.url,
            body: text
          });
        } catch (e) {
          console.log(
            '%cError: parent.postMessage - ',
            'color: red; background: yellow',
            e, event.origin, event.data);
        }
      });

      timeoutTimer = window.setTimeout(() => {
        isTimeout = true;
        postToParent(type, {
          sessionId: sessionId,
          status: 'timeout',
          command: 'loadUrl',
          url: data.url
        });
      }, 30000);

    });

    try {
      postToParent(type, { status: 'initialized' });
    } catch (e) {
      console.log('err', e);
    }
  };


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

  var MIN_JQ = 10000600000;
  const getJQVer = function() {
    if (!window.jQuery) {
      return 0;
    }
    var ver = [];
    var t = window.jQuery.fn.jquery.split('.');
    while(t.length < 3) { t.push(0); }
    _.each(t, (v) => { ver.push((v * 1 + 100000).toString().substr(1)); });
    return ver.join('') * 1;
  };

  const loadJq = function() {
    window.console.log('JQVer: ', getJQVer());
    window.console.info('load jQuery from cdn...');

    return new Promise((resolve, reject) => {
      var $j = window.jQuery || null;
      var $$ = window.$ || null;
      var script = document.createElement('script');
      script.id = 'mp_jQueryLoader';
      script.setAttribute('type', 'text/javascript');
      script.setAttribute('charset', 'UTF-8');
      script.src = 'https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js';
      document.body.appendChild(script);
      var count = 0;

      var tm = window.setInterval(() => {
        count++;

        if (getJQVer() >= MIN_JQ)  {
          window.clearInterval(tm);
          window.mpJQuery = window.jQuery;
          if ($j) { window.jQuery = $j; }
          if ($$) { window.$      = $$; }
          resolve();
        }

        if (count >= 100) {
          window.clearInterval(tm);
          window.console.error('load jQuery timeout');
          reject();
        }

      }, 300);
    });
  };





  const host = window.location.host || '';
  //const href = (location.href || '').replace(/#.*$/, '');
  //const prot = location.protocol;
  if (host === 'ext.nicovideo.jp' &&
      window.name.indexOf('thumbInfo' + PRODUCT + 'Loader') >= 0) {
    thumbInfoApi();
  } else if (window === top) {
    if (getJQVer() >= MIN_JQ) {
      loadGm();
    } else {
      loadJq().then(loadGm);
    }
  }
})();