NicoVideoStoryboard

シークバーに出るサムネイルを並べて表示

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name           NicoVideoStoryboard
// @namespace      https://github.com/segabito/
// @description    シークバーに出るサムネイルを並べて表示
// @match          http://www.nicovideo.jp/watch/*
// @match          http://flapi.nicovideo.jp/api/getflv*
// @match          http://*.nicovideo.jp/smile*
// @grant          none
// @author         segabito macmoto
// @version        1.3.5
// ==/UserScript==

// ver 1.1.2  仕様変更への暫定対応(後で調べてちゃんと直す)

// ver 1.1.0  Chrome + Tampermonkeyでも動くようにした

// ver 1.0.10 長時間動画でCookieの期限が切れるとサムネイルが取得できない問題に対処

// ver 1.0.6  CustomGinzaWatchと併用した時に干渉するのを調整

// ver 1.0.5  デモモードを追加。連続再生時、サムネイルの無い動画をスキップする (iPadで見せびらかす用モード)

// ver 1.0.4  タッチパネルでの操作を改善 (Windows Chromeのみ。Firefoxはいまいち)

// ver 1.0.3  WUXGAモニターフルスクリーン時に動画部分が1920x1080になるよう調整

// ver 1.0.1  フルスクリーンモードで開いた時はプレイヤー領域を押し上げるようにした

(function() {
  var monkey = function() {
    var DEBUG = !true;
    var require = window.require; // , define = window.define;
    var $ = require('jquery'), _ = require('lodash'), WatchApp = require('WatchApp');
    var inherit = require('cjs!inherit');
    var emitter = require('cjs!emitter');

    var __css__ = (function() {/*
      .xDomainLoaderFrame {
        position: fixed;
        top: -999px;
        left: -999px;
        width: 1px;
        height: 1px;
        border: 0;
      }

      .storyboardContainer {
        position: fixed;
        bottom: -300px;
        left: 0;
        right: 0;
        width: 100%;
        box-sizing: border-box;
        -moz-box-sizing: border-box;
        -webkit-box-sizing: border-box;
        background: #999;
        border: 2px outset #000;
        z-index: 9005;
        overflow: visible;
        border-radius: 10px 10px 0 0;
        border-width: 2px 2px 0;
        box-shadow: 0 -2px 2px #666;

        transition: bottom 0.5s ease-in-out;
      }

      {* FlashPlayerに重なる可能性のない状況ではGPUレイヤーにする *}
      .full_with_brower .storyboardContainer,
      .videoExplorer    .storyboardContainer {
        transform: translateZ(0);
        -webkit-transform: translateZ(0);
      }

      .storyboardContainer.withCustomGinzaWatch {
        z-index: 100000;
      }

      .storyboardContainer.show {
        bottom: 0;
      }

      .storyboardContainer .storyboardInner {
        display: none;
        position: relative;
        text-align: center;
        overflow-x: scroll;
        white-space: nowrap;
        background: #222;
        margin: 4px 12px 3px;
        border-style: inset;
        border-width: 2px 4px;
        border-radius: 10px 10px 0 0;
      }

      .storyboardContainer.success .storyboardInner {
        display: block;
      }

      .storyboardContainer .storyboardInner .boardList {
        overflow: hidden;
      }

      .storyboardContainer .boardList .board {
        display: inline-block;
        cursor: pointer;
        background-color: #101010;
      }

      .storyboardContainer.clicked .storyboardInner * {
        opacity: 0.3;
        pointer-events: none;
      }

      .storyboardContainer.opening .storyboardInner .boardList .board {
        pointer-events: none;
      }

      .storyboardContainer .boardList .board.lazyImage:not(.hasSub) {
        background-color: #ccc;
        cursor: wait;
      }
      .storyboardContainer .boardList .board.loadFail {
        background-color: #c99;
      }

      .storyboardContainer .boardList .board > div {
        white-space: nowrap;
      }
      .storyboardContainer .boardList .board .border {
        box-sizing: border-box;
        -moz-box-sizing: border-box;
        -webkit-box-sizing: border-box;
        border-style: solid;
        border-color: #000 #333 #000 #999;
        border-width: 0     2px    0  2px;
        display: inline-block;
      }
      .storyboardContainer .boardList .board:hover .border {
        display: none; {* クリックできなくなっちゃうので苦し紛れの対策 もうちょっとマシな方法を考える *}
      }

      .storyboardContainer .storyboardHeader {
        position: relative;
        width: 100%;
      }
      .storyboardContainer .pointer {
        position: absolute;
        bottom: -15px;
        left: 50%;
        width: 32px;
        margin-left: -16px;
        color: #333;
        z-index: 9010;
        text-align: center;
      }

      .storyboardContainer .cursorTime {
        display: none;
        position: absolute;
        bottom: -30px;
        left: -999px;
        font-size: 10pt;
        border: 1px solid #000;
        z-index: 9010;
        background: #ffc;
        pointer-events: none;
      }
      .storyboardContainer:hover .cursorTime {
        display: block;
      }

      .storyboardContainer.clicked .cursorTime,
      .storyboardContainer.opening .cursorTime {
        display: none;
      }


      .storyboardContainer .setToDisable {
        position: absolute;
        display: inline-block;
        right: 300px;
        bottom: -32px;
        transition: bottom 0.3s ease-in-out;
      }
      .storyboardContainer:hover .setToDisable {
        bottom: 0;
      }

      .storyboardContainer .setToDisable button,
      .setToEnableButtonContainer button {
        background: none repeat scroll 0 0 #999;
        border-color: #666;
        border-radius: 18px 18px 0 0;
        border-style: solid;
        border-width: 2px 2px 0;
        width: 200px;
        overflow: auto;
        white-space: nowrap;
        cursor: pointer;
        box-shadow: 0 -2px 2px #666;
      }

      .full_with_browser .setToEnableButtonContainer button {
        box-shadow: none;
        color: #888;
        background: #000;
      }

      .full_with_browser .storyboardContainer .setToDisable,
      .full_with_browser .setToEnableButtonContainer {
        background: #000; {* Firefox対策 *}
      }

      .setToEnableButtonContainer button {
        width: 200px;
      }

      .storyboardContainer .setToDisable button:hover,
      .setToEnableButtonContainer:not(.loading):not(.fail) button:hover {
        background: #ccc;
        transition: none;
      }

      .storyboardContainer .setToDisable button.clicked,
      .setToEnableButtonContainer.loading button,
      .setToEnableButtonContainer.fail button,
      .setToEnableButtonContainer button.clicked {
        border-style: inset;
        box-shadow: none;
      }

      .setToEnableButtonContainer {
        position: fixed;
        z-index: 9003;
        right: 300px;
        bottom: 0px;
        transition: bottom 0.5s ease-in-out;
      }
      .setToEnableButtonContainer.withCustomGinzaWatch {
        z-index: 99999;
      }

      .setToEnableButtonContainer.loadingVideo {
        bottom: -50px;
      }

      .setToEnableButtonContainer.loading *,
      .setToEnableButtonContainer.loadingVideo *{
        cursor: wait;
        font-size: 80%;
      }
      .setToEnableButtonContainer.fail {
        color: #999;
        cursor: default;
        font-size: 80%;
      }

      .NicoVideoStoryboardSettingMenu {
        height: 44px !important;
      }
      .NicoVideoStoryboardSettingMenu a {
        font-weight: bolder;
      }
      #NicoVideoStoryboardSettingPanel {
        position: fixed;
        bottom: 2000px; right: 8px;
        z-index: -1;
        width: 500px;
        background: #f0f0f0; border: 1px solid black;
        padding: 8px;
        transition: bottom 0.4s ease-out;
        text-align: left;
      }
      #NicoVideoStoryboardSettingPanel.open {
        display: block;
        bottom: 8px;
        box-shadow: 0 0 8px black;
        z-index: 10000;
      }
      #NicoVideoStoryboardSettingPanel .close {
        position: absolute;
        cursor: pointer;
        right: 8px; top: 8px;
      }
      #NicoVideoStoryboardSettingPanel .panelInner {
        background: #fff;
        border: 1px inset;
        padding: 8px;
        min-height: 300px;
        overflow-y: scroll;
        max-height: 500px;
      }
      #NicoVideoStoryboardSettingPanel .panelInner .item {
        border-bottom: 1px dotted #888;
        margin-bottom: 8px;
        padding-bottom: 8px;
      }
      #NicoVideoStoryboardSettingPanel .panelInner .item:hover {
        background: #eef;
      }
      #NicoVideoStoryboardSettingPanel .windowTitle {
        font-size: 150%;
      }
      #NicoVideoStoryboardSettingPanel .itemTitle {
      }
      #NicoVideoStoryboardSettingPanel label {
        margin-right: 12px;
      }
      #NicoVideoStoryboardSettingPanel small {
        color: #666;
      }
      #NicoVideoStoryboardSettingPanel .expert {
        margin: 32px 0 16px;
        font-size: 150%;
        background: #ccc;
      }

      body.full_with_browser #divrightbar,
      body.full_with_browser #divrightbar1,
      body.full_with_browser #divrightbar2,
      body.full_with_browser #divrightbar3,
      body.full_with_browser #divrightbar4,
      body.full_with_browser #divrightbar5,
      body.full_with_browser #divrightbar6,
      body.full_with_browser #divrightbar7,
      body.full_with_browser #divrightbar8,
      body.full_with_browser #divrightbar9,
      body.full_with_browser #divrightbar10,
      body.full_with_browser #divrightbar11,
      body.full_with_browser #divrightbar12 {
        display: none !important;
      }

    */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1].replace(/\{\*/g, '/*').replace(/\*\}/g, '*/');

    var storyboardTemplate = [
      '<div id="storyboardContainer" class="storyboardContainer">',
        '<div class="storyboardHeader">',
          '<div class="setToDisable"><button>閉じる ▼</button></div>',
          '<div class="pointer">▼</div>',
          '<div class="cursorTime"></div>',
        '</div>',

        '<div class="storyboardInner"></div>',
        '<div class="failMessage">',
        '</div>',
      '</div>',
      '',
    ''].join('');

 // マスコットキャラクターのサムネーヨちゃん
    var noThumbnailAA = (function() {/*
 ∧ ∧     ┌─────────────
 ( ´ー`)   < サムネーヨ
  \ <     └───/|────────
   \.\______//
     \       /
      ∪∪‾∪∪
    */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1].replace(/\{\*/g, '/*').replace(/\*\}/g, '*/');

    var EventDispatcher = (function() {

      function AsyncEventDispatcher() {
      }
      inherit(AsyncEventDispatcher, emitter);

      AsyncEventDispatcher.prototype.dispatchEvent = AsyncEventDispatcher.prototype.emit;
      AsyncEventDispatcher.prototype.addEventListener = AsyncEventDispatcher.prototype.on;
      AsyncEventDispatcher.prototype.removeEventListener = AsyncEventDispatcher.prototype.off;

      _.assign(AsyncEventDispatcher.prototype, {

        dispatchAsync: function() {
          var args = arguments;

          window.setTimeout($.proxy(function() {
            try {
              this.dispatchEvent.apply(this, args);
            } catch (e) {
              console.log(e);
            }
          }, this), 0);
        }
      });
      return AsyncEventDispatcher;
    })();

    window.NicoVideoStoryboard =
      (function() {
        return {
          api: {},
          model: {},
          view: {},
          controller: {},
          external: {},
          event: {}
        };
      })();

//    var console =
//      (function(debug) {
//        var n = window._.noop;
//        return debug ? window.console : {log: n, trace: n, time: n, timeEnd: n};
//      })(DEBUG);
    var console = window.console;

    window.NicoVideoStoryboard.event.windowEventDispatcher = (function() {
      var eventDispatcher = new EventDispatcher();

        var onMessage = function(event) {
          if (event.origin.indexOf('nicovideo.jp') < 0) return;
          try {
            var data = JSON.parse(event.data);
            if (data.id !== 'NicoVideoStoryboard') { return; }

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

        window.addEventListener('message', onMessage);

      return eventDispatcher;
    })();

    var initializeWatchController = function() {
      window.NicoVideoStoryboard.external.watchController = (function() {
        var PlayerInitializer = require('watchapp/init/PlayerInitializer');
        var PlaylistInitializer = require('watchapp/init/PlaylistInitializer');
        var CommonModelInitializer = require('watchapp/init/CommonModelInitializer');
        var WatchInfoModel = require('watchapp/model/WatchInfoModel');

        var nicoPlayerConnector = PlayerInitializer.nicoPlayerConnector;
        var viewerInfoModel     = CommonModelInitializer.viewerInfoModel;
        var playerAreaConnector = PlayerInitializer.playerAreaConnector;
        var playlist            = PlaylistInitializer.playlist;
        var watchInfoModel      = WatchInfoModel.getInstance();
        var externalNicoplayer;

        var watchController = new EventDispatcher();

        var getVpos = function() {
          return nicoPlayerConnector.getVpos();
        };
        var setVpos = function(vpos) {
          nicoPlayerConnector.seekVideo(vpos);
        };

        var _isPlaying = null;
        var isPlaying = function() {
          if (_isPlaying !== null) {
            return _isPlaying;
          }
          if (!externalNicoplayer) {
            externalNicoplayer = $("#external_nicoplayer")[0];
          }
          var status = externalNicoplayer.ext_getStatus();
          return status === 'playing';
        };
        var play = function() {
          nicoPlayerConnector.playVideo();
        };
        var pause = function() {
          nicoPlayerConnector.stopVideo();
        };

        var isPremium = function() {
          return !!viewerInfoModel.isPremium;
        };

        var getWatchId = function() {// スレッドIDだったりsmXXXXだったり
          return watchInfoModel.v;
        };
        var getVideoId = function() {// smXXXXXX, soXXXXX など
          return watchInfoModel.id;
        };

        var popupMarquee = require('watchapp/init/PopupMarqueeInitializer').popupMarqueeViewController;
        var popup = {
          message: function(text) {
            popupMarquee.onData(
              '<span style="background: black;">' + text + '</span>'
            );
          },
          alert: function(text) {
            popupMarquee.onData(
              '<span style="background: black; color: red;">' + text + '</span>'
            );
          }
        };

        var _playlist = {
          isContinuous: function() {
            return playlist.isContinuous();
          },
          playNext: function() {
            nicoPlayerConnector.playNextVideo();
          },
          playPrev: function() {
            nicoPlayerConnector.playpreviousVideo();
          }
        };

        playerAreaConnector.addEventListener('onVideoPlayed', function() {
          _isPlaying = true;
          watchController.dispatchEvent('onVideoPlayed');
        });
        playerAreaConnector.addEventListener('onVideoStopped', function() {
          _isPlaying = false;
          watchController.dispatchEvent('onVideoStopped');
        });

        playerAreaConnector.addEventListener('onVideoStarted', function() {
          _isPlaying = true;
          watchController.dispatchEvent('onVideoStarted');
        });
        playerAreaConnector.addEventListener('onVideoEnded', function() {
          _isPlaying = false;
          watchController.dispatchEvent('onVideoEnded');
        });

        playerAreaConnector.addEventListener('onVideoSeeking', function() {
          //console.log('%conVideoSeeking', 'background: cyan');
          watchController.dispatchEvent('onVideoSeeking');
        });
        playerAreaConnector.addEventListener('onVideoSeeked', function() {
          //console.log('%conVideoSeeked', 'background: cyan');
          watchController.dispatchEvent('onVideoSeeked');
        });

        playerAreaConnector.addEventListener('onVideoInitialized', function() {
          watchController.dispatchEvent('onVideoInitialized');
        });

        watchInfoModel.addEventListener('reset', function() {
          watchController.dispatchEvent('onWatchInfoReset');
        });

        var isWatchItLaterExist = function() {
          return !!window.WatchItLater;
        };
        var isShinjukuWatchExist = function() {
          return !!window.ShinjukuWatch;
        };

        var isCustomGinzaWatchExist = function() {
          return $('body>#prefDiv').length > 0;
        };


        _.assign(watchController, {
          getVpos: getVpos,
          setVpos: setVpos,

          isPlaying: isPlaying,
          play:  play,
          pause: pause,

          isPremium: isPremium,

          getWatchId: getWatchId,
          getVideoId: getVideoId,

          popup: popup,
          playlist: _playlist,

          isWatchItLaterExist:     isWatchItLaterExist,
          isShinjukuWatchExist:    isShinjukuWatchExist,
          isCustomGinzaWatchExist: isCustomGinzaWatchExist
        });

        return watchController;
      })();
    };


    window.NicoVideoStoryboard.api.getflv = (function() {
      var BASE_URL = 'http://flapi.nicovideo.jp/api/getflv/';
      var loaderFrame, loaderWindow, cache = {};
      var eventDispatcher = window.NicoVideoStoryboard.event.windowEventDispatcher;
      var getflv = new EventDispatcher();

      var parseInfo = function(q) {
        var info = {}, lines = q.split('&');
        $.each(lines, function(i, line) {
          var tmp = line.split('=');
          var key = window.unescape(tmp[0]), value = window.unescape(tmp[1]);
          info[key] = value;
        });
        return info;
      };

      var onMessage = function(data, type) {
        if (type !== 'getflv') { return; }
        var info = parseInfo(data.info), url = data.url;

        cache[url] = info;
        //console.log('getflv.onGetflvLoad', info);
        getflv.dispatchAsync('onGetflvLoad', info);
      };

      var initialize = function() {
        initialize = _.noop;

        console.log('%c initialize getflv', 'background: lightgreen;');

        loaderFrame = document.createElement('iframe');
        loaderFrame.name      = 'getflvLoader';
        loaderFrame.className = DEBUG ? 'xDomainLoaderFrame debug' : 'xDomainLoaderFrame';
        document.body.appendChild(loaderFrame);

        loaderWindow = loaderFrame.contentWindow;

        eventDispatcher.addEventListener('onMessage', onMessage);
      };

      var load = function(watchId) {
        initialize();
        var pim = require('watchapp/init/PlayerInitializer').playerInitializeModel;
        if (pim && pim.flashVars && pim.flashVars.flvInfo) {
          console.log('%cflashVars.flvInfo exist', 'background: lightgreen;');
          getflv.dispatchAsync('onGetflvLoad', parseInfo(unescape(pim.flashVars.flvInfo)));
          return;
        }
        var url = BASE_URL + watchId;
        //console.log('getflv: ', url);

        getflv.dispatchEvent('onGetflvLoadStart', watchId);
        if (cache[url]) {
          //console.log('%cgetflv cache exist', 'background: cyan', url);
          getflv.dispatchAsync('onGetflvLoad', cache[url]);
        } else {
          loaderWindow.location.replace(url);
        }
      };

      _.assign(getflv, {
        load: load
      });

      return getflv;
    })();

    window.NicoVideoStoryboard.api.thumbnailInfo = (function() {
      var getflv = window.NicoVideoStoryboard.api.getflv;
      var loaderFrame, loaderWindow, cache = {};
      var eventDispatcher = window.NicoVideoStoryboard.event.windowEventDispatcher;
      var thumbnailInfo = new EventDispatcher();

      var onGetflvLoad = function(info) {
        //console.log('thumbnailInfo.onGetflvLoad', info);
        if (!info.url) {
          thumbnailInfo.dispatchAsync(
            'onThumbnailInfoLoad',
            {status: 'ng', message: 'サムネイル情報の取得に失敗しました'}
            );
          return;
        } else
        if (info.url.indexOf('http://') !== 0) { // rtmpe:~など
          thumbnailInfo.dispatchAsync(
            'onThumbnailInfoLoad',
            {status: 'ng', message: 'この配信形式には対応していません'}
            );
          return;
        }

        var url = info.url + '&sb=1';
        // Tampermonkeyでは、Content-type: text/xmlのページでスクリプトを実行できないため、
        // いったんxmlと同一ドメインにあるサムネイルを表示してそこ経由でスクリプトを起動する
        var thumbnailUrl = info.url.replace(/smile\?.=(\d+).*$/, 'smile?i=$1');

        thumbnailInfo.dispatchEvent('onThumbnailInfoLoadStart');
        if (cache[url]) {
          //console.log('%cthumbnailInfo cache exist', 'background: cyan', url);
          thumbnailInfo.dispatchAsync('onThumbnailInfoLoad', cache[url]);
          return;
        }

        if (window.navigator.userAgent.toLowerCase().indexOf('webkit') < -1) {
          // Firefox + Greasemonkey
          loaderWindow.location.replace(url);
        } else {
          // Chrome (Tampermonkey)
          loaderWindow.location.replace(thumbnailUrl + '#' + url);
        }
      };

      var onMessage = function(data, type) { // onThumbnailInfoLoadMessage
        if (type !== 'storyboard') { return; }
        //console.log('thumbnailInfo.onMessage: ', data, type);

        var url = data.url;
        var xml = data.xml, $xml = $(xml), $storyboard = $xml.find('storyboard');

        if ($storyboard.length < 1) {
          thumbnailInfo.dispatchAsync(
            'onThumbnailInfoLoad',
            {status: 'ng', message: 'この動画にはサムネイルがありません'}
            );
          return;
        }

        var info = {
          status:   'ok',
          message:  '成功',
          url:      data.url,
          movieId:  $xml.find('movie').attr('id'),
          duration: $xml.find('duration').text(),
          storyboard: []
        };

        for (var i = 0, len = $storyboard.length; i < len; i++) {
          var sbInfo = parseStoryboard($($storyboard[i]), url);
          info.storyboard.push(sbInfo);
        }
        info.storyboard.sort(function(a, b) {
          var idA = parseInt(a.id.substr(1), 10), idB = parseInt(b.id.substr(1), 10);
          return (idA < idB) ? 1 : -1;
        });
        console.log('%cstoryboard info', 'background: cyan', info);

        cache[url] = info;
        thumbnailInfo.dispatchAsync('onThumbnailInfoLoad', info);
      };

      var parseStoryboard = function($storyboard, url) {
        var storyboardId = $storyboard.attr('id') || '1';
        return {
          id:       storyboardId,
          url:      url.replace('sb=1', 'sb=' + storyboardId),
          thumbnail:{
            width:    $storyboard.find('thumbnail_width').text(),
            height:   $storyboard.find('thumbnail_height').text(),
            number:   $storyboard.find('thumbnail_number').text(),
            interval: $storyboard.find('thumbnail_interval').text()
          },
          board: {
            rows:   $storyboard.find('board_rows').text(),
            cols:   $storyboard.find('board_cols').text(),
            number: $storyboard.find('board_number').text()
          }
        };
      };

      var initialize = function() {
        initialize = _.noop;

        console.log('%c initialize thumbnailInfo', 'background: lightgreen;');

        loaderFrame = document.createElement('iframe');
        loaderFrame.name      = 'StoryboardLoader';
        loaderFrame.className = DEBUG ? 'xDomainLoaderFrame debug' : 'xDomainLoaderFrame';
        document.body.appendChild(loaderFrame);

        loaderWindow = loaderFrame.contentWindow;

        eventDispatcher.addEventListener('onMessage', onMessage);
        getflv.addEventListener('onGetflvLoad', onGetflvLoad);
      };

      var load = function(watchId) {
        initialize();
        getflv.load(watchId);
      };


      _.assign(thumbnailInfo, {
        load: load
      });

      return thumbnailInfo;
    })();

    window.NicoVideoStoryboard.model.StoryboardModel = (function() {

      function StoryboardModel(params) {
        this._thumbnailInfo   = params.thumbnailInfo;
        this._isEnabled       = params.isEnabled;
        this._watchId         = params.watchId;

        WatchApp.extend(this, StoryboardModel, EventDispatcher);
      }

      _.assign(StoryboardModel.prototype, {
        initialize: function(info) {
          console.log('%c initialize StoryboardModel', 'background: lightgreen;');

          this.update(info);
        },
        update: function(info) {
          if (info.status !== 'ok') {
            _.assign(info, {
              duration: 1,
              url: '',
              storyboard: [{
                id: 1,
                url: '',
                thumbnail: {
                  width: 1,
                  height: 1,
                  number: 1,
                  interval: 1
                },
                board: {
                  rows: 1,
                  cols: 1,
                  number: 1
                }
              }]
            });
          }
          this._info = info;

          this.dispatchEvent('update');
        },

        reset: function() {
          if (this.isEnabled()) {
            window.setTimeout($.proxy(function() {
              this.load();
            }, this), 1000);
          }
          this.dispatchEvent('reset');
        },

        load: function() {
          this._isEnabled = true;
          this._thumbnailInfo.load(this._watchId);
        },

        setWatchId: function(watchId) {
          this._watchId = watchId;
        },

        unload: function() {
          this._isEnabled = false;
          this.dispatchEvent('unload');
        },

        isEnabled: function() {
          return this._isEnabled;
        },

        hasSubStoryboard: function() {
          return this._info.storyboard.length > 1;
        },

        getStatus:   function() { return this._info.status; },
        getMessage:  function() { return this._info.message; },
        getDuration: function() { return parseInt(this._info.duration, 10); },

        getUrl:      function(i) { return this._info.storyboard[i || 0].url; },
        getWidth:    function(i) { return parseInt(this._info.storyboard[i || 0].thumbnail.width, 10); },
        getHeight:   function(i) { return parseInt(this._info.storyboard[i || 0].thumbnail.height, 10); },
        getInterval: function(i) { return parseInt(this._info.storyboard[i || 0].thumbnail.interval, 10); },
        getCount:    function(i) {
          return Math.max(
            Math.ceil(this.getDuration() / Math.max(0.01, this.getInterval())),
            parseInt(this._info.storyboard[i || 0].thumbnail.number, 10)
          );
        },
        getRows:   function(i) { return parseInt(this._info.storyboard[i || 0].board.rows, 10); },
        getCols:   function(i) { return parseInt(this._info.storyboard[i || 0].board.cols, 10); },
        getPageCount: function(i) { return parseInt(this._info.storyboard[i || 0].board.number, 10); },
        getTotalRows: function(i) {
          return Math.ceil(this.getCount(i) / this.getCols(i));
        },

        getPageWidth:    function(i) { return this.getWidth(i)  * this.getCols(i); },
        getPageHeight:   function(i) { return this.getHeight(i) * this.getRows(i); },
        getCountPerPage: function(i) { return this.getRows(i)   * this.getCols(i); },

        /**
         *  nページ目のURLを返す。 ゼロオリジン
         */
        getPageUrl: function(page, storyboardIndex) {
          page = Math.max(0, Math.min(this.getPageCount(storyboardIndex) - 1, page));
          return this.getUrl(storyboardIndex) + '&board=' + (page + 1);
        },

        /**
         * vposに相当するサムネは何番目か?を返す
         */
        getIndex: function(vpos, storyboardIndex) {
          // msec -> sec
          var v = Math.floor(vpos / 1000);
          v = Math.max(0, Math.min(this.getDuration(), v));

          // サムネの総数 ÷ 秒数
          // Math.maxはゼロ除算対策
          var n = this.getCount(storyboardIndex) / Math.max(1, this.getDuration());

          return parseInt(Math.floor(v * n), 10);
        },

        /**
         * Indexのサムネイルは何番目のページにあるか?を返す
         */
        getPageIndex: function(thumbnailIndex, storyboardIndex) {
          var perPage   = this.getCountPerPage(storyboardIndex);
          var pageIndex = parseInt(thumbnailIndex / perPage, 10);
          return Math.max(0, Math.min(this.getPageCount(storyboardIndex), pageIndex));
        },

        /**
         *  vposに相当するサムネは何ページの何番目にあるか?を返す
         */
        getThumbnailPosition: function(vpos, storyboardIndex) {
          var thumbnailIndex = this.getIndex(vpos, storyboardIndex);
          var pageIndex      = this.getPageIndex(thumbnailIndex);

          var mod = thumbnailIndex % this.getCountPerPage(storyboardIndex);
          var row = Math.floor(mod / Math.max(1, this.getCols()));
          var col = mod % this.getRows(storyboardIndex);

          return {
            page: pageIndex,
            index: thumbnailIndex,
            row: row,
            col: col
          };
        },

        /**
         * nページ目のx, y座標をvposに変換して返す
         */
        getPointVpos: function(x, y, page, storyboardIndex) {
          var width  = Math.max(1, this.getWidth(storyboardIndex));
          var height = Math.max(1, this.getHeight(storyboardIndex));
          var row = Math.floor(y / height);
          var col = Math.floor(x / width);
          var mod = x % width;


          // 何番目のサムネに相当するか?
          var point =
            page * this.getCountPerPage(storyboardIndex) +
            row  * this.getCols(storyboardIndex)         +
            col +
            (mod / width) // 小数点以下は、n番目の左端から何%あたりか
            ;

          // 全体の何%あたり?
          var percent = point / Math.max(1, this.getCount(storyboardIndex));
          percent = Math.max(0, Math.min(100, percent));

          // vposは㍉秒単位なので1000倍
          return Math.floor(this.getDuration() * percent * 1000);
        },

        /**
         * vposは何ページ目に当たるか?を返す
         */
        getVposPage: function(vpos, storyboardIndex) {
          var index = this._storyboard.getIndex(vpos, storyboardIndex);
          var page  = this._storyboard.getPageIndex(index, storyboardIndex);

          return page;
        },

        /**
         * nページ目のCols, Rowsがsubではどこになるかを返す
         */
        getPointPageColAndRowForSub: function(page, row, col) {
          var mainPageCount = this.getCountPerPage();
          var subPageCount  = this.getCountPerPage(1);
          var mainCols = this.getCols();
          var subCols = this.getCols(1);

          var mainIndex = mainPageCount * page + mainCols * row + col;
          var subOffset = mainIndex % subPageCount;

          var subPage = Math.floor(mainIndex / subPageCount);
          var subRow = Math.floor(subOffset / subCols);
          var subCol = subOffset % subCols;

          return {
            page: subPage,
            row: subRow,
            col: subCol
          };
        },

      });

      return StoryboardModel;
    })();

    window.NicoVideoStoryboard.view.FullScreenModeView = (function() {
      var __TEMPLATE__ = (function() {/*
        body.full_with_browser{
          background: #000;
        }
        body.full_with_browser.NicoVideoStoryboardOpen #content{
          margin-bottom: {$storyboardHeight}px;
          transition: margin-bottom 0.5s ease-in-out;
        }


        {* フルスクリーン関係ないけど一旦ここに... *}
        body.NicoVideoStoryboardOpen #footer {
          min-height: {$storyboardHeight}px;
        }
        body.NicoVideoStoryboardOpen.videoExplorer #content.w_adjusted .videoExplorerMenuInner,
        body.NicoVideoStoryboardOpen #leftPanel.sidePanel .sideVideoInfo .videoDescription {
          margin-bottom: {$storyboardHeight}px;
        }
        body.NicoVideoStoryboardOpen #divrightbar,
        body.NicoVideoStoryboardOpen #divrightbar1,
        body.NicoVideoStoryboardOpen #divrightbar2,
        body.NicoVideoStoryboardOpen #divrightbar3,
        body.NicoVideoStoryboardOpen #divrightbar4,
        body.NicoVideoStoryboardOpen #divrightbar5,
        body.NicoVideoStoryboardOpen #divrightbar6,
        body.NicoVideoStoryboardOpen #divrightbar7,
        body.NicoVideoStoryboardOpen #divrightbar8,
        body.NicoVideoStoryboardOpen #divrightbar9,
        body.NicoVideoStoryboardOpen #divrightbar10,
        body.NicoVideoStoryboardOpen #divrightbar11,
        body.NicoVideoStoryboardOpen #divrightbar12
        {
          height: calc(100% - {$storyboardHeight}px);
        }

       */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1].replace(/\{\*/g, '/*').replace(/\*\}/g, '*/');

      var addStyle = function(styles, id) {
        var elm = document.createElement('style');
        window.setTimeout(function() {
          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);
        }, 0);
        return elm;
      };

      function FullScreenModeView() {

        this._css = null;
        this._lastHeight = -1;
      }

      _.assign(FullScreenModeView.prototype, {
        initialize: function() {
          if (this._css) { return; }

          console.log('%cinitialize NicoVideoStorybaordFullScreenStyle', 'background: lightgreen;');
          this._css = addStyle('/* undefined */', 'NicoVideoStorybaordFullScreenStyle');
        },
        update: function($container) {
          this.initialize();

          var height = $container.outerHeight();

          if (height === this._lastHeight) { return; }
          this._lastHeight = height;

          var newCss = __TEMPLATE__.split('{$storyboardHeight}').join(height);
          this._css.innerHTML = newCss;
        }
      });


      return FullScreenModeView;
    })();

    window.NicoVideoStoryboard.view.SetToEnableButtonView = (function() {

      var TEXT = {
        DEFAULT:   'サムネイルを開く ▲',
        LOADING:   '動画を読み込み中...',
        GETFLV:    '動画情報を読み込み中...',
        THUMBNAIL: 'サムネイル情報を読み込み中...'
      };


      function SetToEnableButtonView(params) {
        this._storyboard      = params.storyboard;
        this._eventDispatcher = params.eventDispatcher;
        this._watchController = params.watchController;

        this.initialize();
      }

      _.assign(SetToEnableButtonView.prototype, {
        initialize: function() {
          console.log('%c initialize SetToEnableButtonView', 'background: lightgreen;');
          this._$view = $([
              '<div class="setToEnableButtonContainer loadingVideo">',
                '<button>', TEXT.DEFAULT, '</button>',
              '</div>',
              '',
              '',
            ''].join(''));
          this._$button = this._$view.find('button');
          this._$button.on('click', $.proxy(this._onButtonClick, this));

          this._$view.toggleClass('withCustomGinzaWatch', this._watchController.isCustomGinzaWatchExist());

          var sb = this._storyboard;
          sb.addEventListener('reset',  $.proxy(this._onStoryboardReset, this));
          sb.addEventListener('update', $.proxy(this._onStoryboardUpdate, this));

          var evt = this._eventDispatcher;
          evt.addEventListener('getFlvLoadStart',
            $.proxy(this._onGetflvLoadStart, this));
          evt.addEventListener('onThumbnailInfoLoadStart',
            $.proxy(this._onThumbnailInfoLoadStart, this));
          evt.addEventListener('onWatchInfoReset',
            $.proxy(this._onWatchInfoReset, this));

          $('body').append(this._$view);
        },
        reset: function() {
          this._$view.attr('title', '');
          if (this._storyboard.isEnabled()) {
            this._$view.removeClass('loadingVideo getflv thumbnailInfo fail success').addClass('loading');
            this._setText(TEXT.GETFLV);
          } else {
            this._$view.removeClass('loadingVideo getflv thumbnailInfo fail success loading');
            this._setText(TEXT.DEFAULT);
          }
        },
        _setText: function(text) {
          this._$button.text(text);
        },
        _onButtonClick: function(e) {
          if (
            this._$view.hasClass('loading') ||
            this._$view.hasClass('loadingVideo') ||
            this._$view.hasClass('fail')) {
            return;
          }
          e.preventDefault();
          e.stopPropagation();

          var $view = this._$view.addClass('loading clicked');
          this._eventDispatcher.dispatchEvent('onEnableStoryboard');
          window.setTimeout(function() {
            $view.removeClass('clicked');
            $view = null;
          }, 1000);
        },
        _onStoryboardReset: function() {
          this.reset();
        },
        _onStoryboardUpdate: function() {
          var storyboard = this._storyboard;

          if (storyboard.getStatus() === 'ok') {
            window.setTimeout($.proxy(function() {
              this._$view
                .removeClass('loading getflv thumbnailInfo')
                .addClass('success')
                .attr('title', '');
              this._setText(TEXT.DEFAULT);
            }, this), 3000);
          } else {
            this._$view
              .removeClass('loading')
              .addClass('fail')
              .attr('title', DEBUG ? noThumbnailAA : '');
            this._setText(storyboard.getMessage());
          }
        },
        _onGetflvLoadStart: function() {
          this._$view.addClass('loading getflv');
          this._setText(TEXT.GETFLV);
        },
        _onThumbnailInfoLoadStart: function() {
          this._$view.addClass('loading thumbnailInfo');
          this._setText(TEXT.THUMBNAIL);
        },
        _onWatchInfoReset: function() {
          this._$view.addClass('loadingVideo');
          this._setText(TEXT.LOADING);
        }
      });

      return SetToEnableButtonView;
    })();

    var RequestAnimationFrame = function(callback, frameSkip) {
      this.initialize(callback, frameSkip);
    };
    _.assign(RequestAnimationFrame.prototype, {
      initialize: function(callback, frameSkip) {
        this.requestAnimationFrame = _.bind((window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame), window);
        this._frameSkip = Math.max(0, typeof frameSkip === 'number' ? frameSkip : 0);
        this._frameCount = 0;
        this._callback = callback;
        this._enable = false;
        this._onFrame = _.bind(this._onFrame, this);
      },
      _onFrame: function() {
        if (this._enable) {
          this._frameCount++;
          try {
            if (this._frameCount % (this._frameSkip + 1) === 0) {
              this._callback();
            }
          } catch (e) {
            console.log('%cException!', 'background: red;', e);
          }
          this.requestAnimationFrame(this._onFrame);
        }
      },
      enable: function() {
        if (this._enable) { return; }
        this._enable = true;
        this.requestAnimationFrame(this._onFrame);
      },
      disable: function() {
        this._enable = false;
      }
    });


    var StoryboardBlockList =
    window.NicoVideoStoryboard.view.StoryboardBlockList = (function() {
      var StoryboardBlock = function(option) {
        this.initialize(option);
      };
      _.assign(StoryboardBlock.prototype, {
        initialize: function(option) {
          var height = option.boardHeight;

          this._backgroundPosition = '0 -' + height * option.row + 'px';
          this._src = option.src;
          this._page = option.page;
          this._isLoaded = false;

          var $view = $('<div class="board"/>')
            .css({
              width: option.pageWidth,
              height: height,
              backgroundPosition: '0 -' + height * option.row + 'px'
            })
            .attr({
              'data-src': option.src,
              'data-page': option.page,
              'data-top': height * option.row + height / 2,
              'data-backgroundPosition': this._backgroundPosition
            })
            .append(option.$inner);

           if (option.page === 0) {
              this._isLoaded = true;
              $view.css('background-image', 'url(' + option.src + ')');
           } else {
             // subStoryboardへの手抜き対応
             // subの一行あたりの行数=mainの整数倍 という前提が崩れたら破綻する
             if (option.subOffset) {
               var offset = option.subOffset;
               this.hasSub = true;
               $view
                 .addClass('hasSub')
                 .css({
                 'background-image': 'url(' + option.subSrc  + ')',
                 'background-position': '-' + option.width * offset.col + 'px -' + height * offset.row + 'px',
                 'background-size': '200% auto'
               });
             }
             $view.addClass('lazyImage page-' + option.page);
           }
           this._$view = $view;
         },
         loadImage: function() {
           if (this._isLoaded) {
             return;
           }
           var $view = this._$view;
           var img = new Image();
           img.onload = _.bind(function() {
             $view
             .css({
               'background-image': 'url(' + this._src + ')',
               'background-position': this._backgroundPosition,
               'background-size': '',
             })
             .removeClass('lazyImage page-' + this._page);
             $view = img = null;
           }, this);
           img.onerror = function() {
             $view = img = null;
           };
           img.src = this._src;
           this._isLoaded = true;
         },
         getPage: function() {
           return this._page;
         },
         getView: function() {
           return this._$view;
         }
      });
      var StoryboardBlockBorder = function(width, height, cols) {
        this.initialize(width, height, cols);
      };
      _.assign(StoryboardBlockBorder.prototype, {
        initialize: function(width, height, cols) {
          var $border = $('<div class="border"/>').css({
            width: width,
            height: height
          });
          var $div = $('<div />');
          for (var i = 0; i < cols; i++) {
            $div.append($border.clone());
          }
          this._$view = $div;
        },
        getView: function() {
          return this._$view.clone();
        }
      });

      var StoryboardBlockList = function(storyboard) {
        this.initialize(storyboard);
      };
      _.assign(StoryboardBlockList.prototype, {
        initialize: function(storyboard) {
          if (storyboard) {
            this.create(storyboard);
          }
        },
        create: function(storyboard) {
          var pages      = storyboard.getPageCount();
          var pageWidth  = storyboard.getPageWidth();
          var width      = storyboard.getWidth();
          var height     = storyboard.getHeight();
          var rows       = storyboard.getRows();
          var cols       = storyboard.getCols();

          var totalRows = storyboard.getTotalRows();
          var rowCnt = 0;
          this._$innerBorder =
            new StoryboardBlockBorder(width, height, cols);
          var $view = $('<div class="boardList"/>')
            .css({
              width: storyboard.getCount() * width,
              paddingLeft: '50%',
              paddingRight: '50%',
              height: height
            });
          this._$view = $view;
          this._blocks = [];
          this._lazyLoaded = [];

          var hasSubStoryboard = storyboard.hasSubStoryboard();
          for (var i = 0; i < pages; i++) {
            var src = storyboard.getPageUrl(i);
            for (var j = 0; j < rows; j++) {
              var option = {
                width: width,
                pageWidth: pageWidth,
                boardHeight: height,
                page: i,
                row: j,
                src: src
              };
              if (i > 0 && hasSubStoryboard) {
                var offset = storyboard.getPointPageColAndRowForSub(i, j, 0);
                option.subOffset = offset;
                option.subSrc = storyboard.getPageUrl(offset.page, 1);
              }
              this.appendBlock(option);

              rowCnt++;
              if (rowCnt >= totalRows) {
                break;
              }
            }
          }

          this._lazyLoadImageTimer =
            window.setTimeout(_.bind(this._lazyLoadAll, this), 1000 * 60 * 4);
        },
        appendBlock: function(option) {
          option.$inner = this._$innerBorder.getView();
          var block = new StoryboardBlock(option);
          this._blocks.push(block);
          this._$view.append(block.getView());
        },
        loadImage: function(pageNumber) {
          if (pageNumber < 1 || this._lazyLoaded[pageNumber]) {
            return;
          }
          this._lazyLoaded[pageNumber] = true;
          for (var i = 0, len = this._blocks.length; i < len; i++) {
            var block = this._blocks[i];
            if (block.getPage() <= pageNumber) {
              block.loadImage();
            }
          }
       },
       _lazyLoadAll: function() {
         console.log('%clazyLoadAll', 'background: cyan;');
         for (var i = 1, len = this._blocks.length; i < len; i++) {
           this._blocks[i].loadImage();
         }
       },
       clear: function() {
         this._$view.remove();
         if (this._lazyLoadImageTimer) {
           window.clearTimeout(this._lazyLoadImageTimer);
         }
       },
       getView: function() {
          return this._$view;
       }
      });

      return StoryboardBlockList;
    })();


    window.NicoVideoStoryboard.view.StoryboardView = (function() {
      var VPOS_RATE = 10;

      function StoryboardView(params) {
        this.initialize(params);
      }

      _.assign(StoryboardView.prototype, {
        initialize: function(params) {
          console.log('%c initialize StoryboardView', 'background: lightgreen;');

          this._watchController = params.watchController;
          var evt = this._eventDispatcher = params.eventDispatcher;
          var sb  = this._storyboard = params.storyboard;

          this._isHover = false;
          this._autoScroll = true;
          this._currentUrl = '';
          this._lazyImage = {};
          this._lastPage = -1;
          this._lastVpos = 0;
          this._lastGetVpos = 0;
          this._timerCount = 0;
          this._scrollLeft = 0;
          this._frameSkip = params.frameSkip || 1;

          this._enableButtonView =
            new window.NicoVideoStoryboard.view.SetToEnableButtonView({
              storyboard:      sb,
              eventDispatcher: this._eventDispatcher,
              watchController: this._watchController
            });

          this._fullScreenModeView =
            new window.NicoVideoStoryboard.view.FullScreenModeView();
          evt.addEventListener('onWatchInfoReset', $.proxy(this._onWatchInfoReset, this));

          sb.addEventListener('update', $.proxy(this._onStoryboardUpdate, this));
          sb.addEventListener('reset',  $.proxy(this._onStoryboardReset,  this));
          sb.addEventListener('unload', $.proxy(this._onStoryboardUnload, this));

          this._requestAnimationFrame = new RequestAnimationFrame(_.bind(this._onTimerInterval, this), this._frameSkip);
        },
        _initializeStoryboard: function() {
          this._initializeStoryboard = _.noop;
          console.log('%cStoryboardView.initializeStoryboard', 'background: lightgreen;');

          var $view = this._$view = $(storyboardTemplate);

          var $inner = this._$inner = $view.find('.storyboardInner');
          this._$failMessage   = $view.find('.failMessage');
          this._$cursorTime    = $view.find('.cursorTime');
          this._$disableButton = $view.find('.setToDisable button');

          $view
            .on('click',     '.board',
                $.proxy(this._onBoardClick, this))
            .on('mousemove', '.board',
                $.proxy(this._onBoardMouseMove, this))
            .on('mousemove', '.board',
                _.debounce($.proxy(this._onBoardMouseMoveEnd, this), 300))
            .on('mousewheel',
                $.proxy(this._onMouseWheel, this))
            .on('mousewheel',
                _.debounce($.proxy(this._onMouseWheelEnd, this), 300))
            .toggleClass('withCustomGinzaWatch', this._watchController.isCustomGinzaWatchExist());

          var self = this;
          var onHoverIn  = function() { self._isHover = true;  };
          var onHoverOut = function() { self._isHover = false; };
          $inner
            .hover(onHoverIn, onHoverOut)
            .on('touchstart',  $.proxy(this._onTouchStart, this))
            .on('touchend',    $.proxy(this._onTouchEnd,   this))
            .on('touchmove',   $.proxy(this._onTouchMove,  this))
            .on('scroll', _.throttle(function() { self._onScroll(); }, 500));

          this._watchController
            .addEventListener('onVideoSeeked', $.proxy(this._onVideoSeeked, this));

          this._watchController
            .addEventListener('onVideoSeeking', $.proxy(this._onVideoSeeking, this));

          this._$disableButton.on('click',
            $.proxy(this._onDisableButtonClick, this));

          $('body')
            .append($view)
            .on('touchend', function() { self._isHover = false; });
        },
        _onBoardClick: function(e) {
          var $board = $(e.target), offset = $board.offset();
          var y = $board.attr('data-top') * 1;
          var x = e.pageX - offset.left;
          var page = $board.attr('data-page');
          var vpos = this._storyboard.getPointVpos(x, y, page);
          if (isNaN(vpos)) { return; }

          var $view = this._$view;
          $view.addClass('clicked');
          window.setTimeout(function() { $view.removeClass('clicked'); }, 1000);
          this._eventDispatcher.dispatchEvent('onStoryboardSelect', vpos);
          this._$cursorTime.css({left: -999});

          this._isHover = false;
          if ($board.hasClass('lazyImage')) { this._lazyLoadImage(page); }
        },
        _onBoardMouseMove: function(e) {
          var $board = $(e.target), offset = $board.offset();
          var y = $board.attr('data-top') * 1;
          var x = e.pageX - offset.left;
          var page = $board.attr('data-page');
          var vpos = this._storyboard.getPointVpos(x, y, page);
          if (isNaN(vpos)) { return; }
          var sec = Math.floor(vpos / 1000);

          var time = Math.floor(sec / 60) + ':' + ((sec % 60) + 100).toString().substr(1);
          this._$cursorTime.text(time).css({left: e.pageX});

          this._isHover = true;
          this._isMouseMoving = true;
          if ($board.hasClass('lazyImage')) { this._lazyLoadImage(page); }
        },
        _onBoardMouseMoveEnd: function(e) {
          this._isMouseMoving = false;
        },
        _onMouseWheel: function(e, delta) {
          e.preventDefault();
          e.stopPropagation();
          this._isHover = true;
          this._isMouseMoving = true;
          var left = this.scrollLeft();
          this.scrollLeft(left - delta * 140);
        },
        _onMouseWheelEnd: function(e, delta) {
          this._isMouseMoving = false;
        },
        _onTouchStart: function(e) {
          e.stopPropagation();
          this._syncScrollLeft();
        },
        _onTouchEnd: function(e) {
          e.stopPropagation();
        },
        _onTouchMove: function(e) {
          e.stopPropagation();
          this._isHover = true;
          this._syncScrollLeft();
        },
        _onTouchCancel: function(e) {
        },
        _onVideoSeeking: function() {
        },
        _onVideoSeeked: function() {
          if (!this._storyboard.isEnabled()) {
            return;
          }
          if (this._storyboard.getStatus() !== 'ok') {
            return;
          }
          var vpos  = this._watchController.getVpos();
          var page = this._storyboard.getVposPage(vpos);

          this._lazyLoadImage(page);
          if (this.isHover || !this._watchController.isPlaying()) {
            this._onVposUpdate(vpos, true);
          } else {
            this._onVposUpdate(vpos);
          }
        },
        update: function() {
          this.disableTimer();

          this._initializeStoryboard();
          this._$view.removeClass('show success');
          $('body').removeClass('NicoVideoStoryboardOpen');
          if (this._storyboard.getStatus() === 'ok') {
            this._updateSuccess();
          } else {
            this._updateFail();
          }
        },
        scrollLeft: function(left) {
          if (left === undefined) {
            return this._scrollLeft;
          } else
          if (left === 0 || Math.abs(this._scrollLeft - left) >= 1) {
            this._$inner[0].scrollLeft = left;
            this._scrollLeft = left;
          }
        },
        /**
         * 現在の動画の位置に即スクロール
         */
        scrollToVpos: function() {
          vpos = this._watchController.getVpos();
          this._onVposUpdate(vpos, true);
        },
        scrollToNext: function() {
          this.scrollLeft(this._storyboard.getWidth());
        },
        scrollToPrev: function() {
          this.scrollLeft(-this._storyboard.getWidth());
        },
        /**
         * 変数として持ってるscrollLeftを実際の値と同期
         */
        _syncScrollLeft: function() {
          this._scrollLeft = this._$inner[0].scrollLeft;
        },
        _updateSuccess: function() {
          var url = this._storyboard.getUrl();
          var $view = this._$view.addClass('opening');

          if (this._currentUrl === url) {
            $view.addClass('show success');
            this.enableTimer();
          } else {
            this._currentUrl = url;
            console.time('createStoryboardDOM');
            this._updateSuccessFull();
            console.timeEnd('createStoryboardDOM');
          }
          $('body').addClass('NicoVideoStoryboardOpen');

          window.setTimeout(function() {
            $view.removeClass('opening');
            $view = null;
          }, 1000);
        },
        _updateSuccessFull: function() {

          var list = new StoryboardBlockList(this._storyboard);
          this._storyboardBlockList = list;
          this._$inner.empty().append(list.getView()).append(this._$pointer);

          var $view = this._$view;
          $view.removeClass('fail').addClass('success');

          this._fullScreenModeView.update($view);

          window.setTimeout(function() { $view.addClass('show'); }, 100);

          this.scrollLeft(0);
          this.enableTimer();
        },
        _lazyLoadImage: function(pageNumber) { //return;
          if (this._storyboardBlockList) {
            this._storyboardBlockList.loadImage(pageNumber);
          }
        },
        _updateFail: function() {
          this._$view.removeClass('success').addClass('fail');
          this.disableTimer();
        },
        clear: function() {
          if (this._$view) {
            this._$inner.empty();
          }
          this.disableTimer();
        },
        _clearTimer: function() {
          this._requestAnimationFrame.disable();
        },
        enableTimer: function() {
          this._clearTimer();
          this._isHover = false;
          this._requestAnimationFrame.enable();
        },
        disableTimer: function() {
          this._clearTimer();
        },
        _onTimerInterval: function() {
          if (this._isHover) { return; }
          if (!this._autoScroll) { return; }
          if (!this._storyboard.isEnabled()) { return; }

          var div = VPOS_RATE;
          var mod = this._timerCount % div;
          this._timerCount++;

          var vpos;

          if (!this._watchController.isPlaying()) {
            return;
          }

          //  getVposが意外に時間を取るので回数を減らす
          // そもそもコメントパネルがgetVpos叩きまくってるんですがそれは
          if (mod === 0) {
            vpos = this._watchController.getVpos();
          } else {
            vpos = this._lastVpos;
          }

          this._onVposUpdate(vpos);
        },
        _onVposUpdate: function(vpos, isImmediately) {
          var storyboard = this._storyboard;
          var duration = Math.max(1, storyboard.getDuration());
          var per = vpos / (duration * 1000);
          var width = storyboard.getWidth();
          var boardWidth = storyboard.getCount() * width;
          var targetLeft = boardWidth * per + width * 0.4;
          var currentLeft = this.scrollLeft();
          var leftDiff = targetLeft - currentLeft;

          if (Math.abs(leftDiff) > 5000) {
            leftDiff = leftDiff * 0.93; // 大きくシークした時
          } else {
            leftDiff = leftDiff / VPOS_RATE;
          }

          this._lastVpos = vpos;

          this.scrollLeft(isImmediately ? targetLeft : (currentLeft + Math.round(leftDiff)));
        },
        _onScroll: function() {
          var storyboard = this._storyboard;
          var scrollLeft = this.scrollLeft();
          var page = Math.round(scrollLeft / (storyboard.getPageWidth() * storyboard.getRows()));
          this._lazyLoadImage(Math.min(page, storyboard.getPageCount() - 1));
        },
        reset: function() {
          this._lastVpos = -1;
          this._lastPage = -1;
          this._currentUrl = '';
          this._timerCount = 0;
          this._scrollLeft = 0;
          this._lazyImage = {};
          this._autoScroll = true;
          if (this._storyboardBlockList) {
            this._storyboardBlockList.clear();
          }
          if (this._$view) {
            $('body').removeClass('NicoVideoStoryboardOpen');
            this._$view.removeClass('show');
            this._$inner.empty();
          }
        },
        _onDisableButtonClick: function(e) {
          e.preventDefault();
          e.stopPropagation();

          var $button = this._$disableButton;
          $button.addClass('clicked');
          window.setTimeout(function() {
            $button.removeClass('clicked');
          }, 1000);

          this._eventDispatcher.dispatchEvent('onDisableStoryboard');
        },
        _onStoryboardUpdate: function() {
          this.update();
        },
        _onStoryboardReset:  function() {
        },
        _onStoryboardUnload: function() {
          $('body').removeClass('NicoVideoStoryboardOpen');
          if (this._$view) {
            this._$view.removeClass('show');
          }
        },
        _onWatchInfoReset:  function() {
          this.reset();
        }
      });

      return StoryboardView;
    })();

    window.NicoVideoStoryboard.controller.StoryboardController = (function() {

      function StoryboardController(params) {
        this.initialize(params);
      }

      _.assign(StoryboardController.prototype, {
        initialize: function(params) {
          console.log('%c initialize StoryboardController', 'background: lightgreen;');

          this._thumbnailInfo   = params.thumbnailInfo;
          this._watchController = params.watchController;
          this._config          = params.config;

          var evt = this._eventDispatcher = params.eventDispatcher;

          evt.addEventListener('onVideoInitialized',
            $.proxy(this._onVideoInitialized, this));

          evt.addEventListener('onWatchInfoReset',
            $.proxy(this._onWatchInfoReset, this));

          evt.addEventListener('onStoryboardSelect',
            $.proxy(this._onStoryboardSelect, this));

          evt.addEventListener('onEnableStoryboard',
            $.proxy(this._onEnableStoryboard, this));

          evt.addEventListener('onDisableStoryboard',
            $.proxy(this._onDisableStoryboard, this));

          evt.addEventListener('onGetflvLoadStart',
            $.proxy(this._onGetflvLoadStart, this));

          evt.addEventListener('onThumbnailInfoLoadStart',
            $.proxy(this._onThumbnailInfoLoadStart, this));

          evt.addEventListener('onThumbnailInfoLoad',
            $.proxy(this._onThumbnailInfoLoad, this));

          this._initializeStoryboard();
        },

        _initializeStoryboard: function() {
          this._initializeStoryboard = _.noop;

          if (!this._storyboardModel) {
            var nsv = window.NicoVideoStoryboard;
            this._storyboardModel = new nsv.model.StoryboardModel({
              thumbnailInfo:   this._thumbnailInfo,
              isEnabled:       this._config.get('enabled') === true,
              watchId:         this._watchController.getWatchId()
            });
          }
          if (!this._storyboardView) {
            this._storyboardView = new window.NicoVideoStoryboard.view.StoryboardView({
              watchController: this._watchController,
              eventDispatcher: this._eventDispatcher,
              storyboard: this._storyboardModel,
              frameSkip: this._config.get('frameSkip')
            });
          }
        },

        load: function(watchId) {
          if (watchId) {
            this._storyboardModel.setWatchId(watchId);
          }
          this._storyboardModel.load();
        },

        unload: function() {
          if (this._storyboardModel) {
            this._storyboardModel.unload();
          }
        },

        _onVideoInitialized: function() {
          this._initializeStoryboard();
          this._storyboardModel.reset();
        },

        _onWatchInfoReset: function() {
          this._storyboardModel.setWatchId(this._watchController.getWatchId());
        },

        _onThumbnailInfoLoad: function(info) {
          //console.log('StoryboardController._onThumbnailInfoLoad', info);

          this._storyboardModel.update(info);
        },

        _onStoryboardSelect: function(vpos) {
          //console.log('_onStoryboardSelect', vpos);
          this._watchController.setVpos(vpos);
        },

        _onEnableStoryboard: function() {
          window.setTimeout($.proxy(function() {
            this._config.set('enabled', true);
            this.load();
          }, this), 0);
        },

        _onDisableStoryboard: function() {
          window.setTimeout($.proxy(function() {
            this._config.set('enabled', false);
            this.unload();
          }, this), 0);
        },

        _onGetflvLoadStart: function() {
        },

        _onThumbnailInfoLoadStart: function() {
        }
      });

      return StoryboardController;
    })();


    _.assign(window.NicoVideoStoryboard, {
      _addStyle: function(styles, id) {
        var elm = document.createElement('style');
        window.setTimeout(function() {
          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);
        }, 0);
        return elm;
      },
      initialize: function() {
        console.log('%c initialize NicoVideoStoryboard', 'background: lightgreen;');
        this._initializeUserConfig();

        this._getflv          = window.NicoVideoStoryboard.api.getflv;
        this._thumbnailInfo   = window.NicoVideoStoryboard.api.thumbnailInfo;
        this._watchController = window.NicoVideoStoryboard.external.watchController;

        this._eventDispatcher = new EventDispatcher();

        if (!this._watchController.isPremium()) {
          this._watchController.popup.alert('NicoVideoStoryboardはプレミアムの機能を使っているため、一般アカウントでは動きません');
          return;
        }

        this._storyboardController = new window.NicoVideoStoryboard.controller.StoryboardController({
          thumbnailInfo:       this._thumbnailInfo,
          watchController:     this._watchController,
          eventDispatcher:     this._eventDispatcher,
          config:              this.config
        });
        this._initializeEvent();
        this._initializeSettingPanel();

        this._addStyle(__css__, 'NicoVideoStoryboardCss');
      },
      _initializeEvent: function() {
        console.log('%c initializeEvent NicoVideoStoryboard', 'background: lightgreen;');
        var eventDispatcher = this._eventDispatcher;

        this._watchController.addEventListener('onWatchInfoReset', function() {
          eventDispatcher.dispatchEvent('onWatchInfoReset');
        });

        this._watchController.addEventListener('onVideoInitialized', function() {
          eventDispatcher.dispatchEvent('onVideoInitialized');
        });

        this._getflv.addEventListener('onGetflvLoadStart', function() {
          eventDispatcher.dispatchEvent('onGetflvLoadStart');
        });
        this._getflv.addEventListener('onGetflvLoad', function(info) {
          eventDispatcher.dispatchEvent('onGetflvLoad', info);
        });

        this._thumbnailInfo.addEventListener('onThumbnailInfoLoadStart', function() {
          eventDispatcher.dispatchEvent('onThumbnailInfoLoadStart');
        });
        this._thumbnailInfo.addEventListener('onThumbnailInfoLoad', $.proxy(function(info) {
          eventDispatcher.dispatchEvent('onThumbnailInfoLoad', info);
          this._onThumbnailInfoLoad(info);
       }, this));
      },
      _initializeUserConfig: function() {
        var prefix = 'NicoStoryboard_';
        var conf = {
          enabled: true,
          autoScroll: true,
          demoMode: false,
          frameSkip: 3
        };

        this.config = {
          get: function(key) {
            try {
              if (window.localStorage.hasOwnProperty(prefix + key)) {
                return JSON.parse(window.localStorage.getItem(prefix + key));
              }
              return conf[key];
            } catch (e) {
              return conf[key];
            }
          },
          set: function(key, value) {
            window.localStorage.setItem(prefix + key, JSON.stringify(value));
          }
        };
      },
      load: function(watchId) {
        // 動画ごとのcookieがないと取得できないので指定できてもあまり意味は無い
        watchId = watchId || this._watchController.getWatchId();
        this._storyboardController.load(watchId);
      },
      _initializeSettingPanel: function() {
        var $menu   = $('<li class="NicoVideoStoryboardSettingMenu"><a href="javascript:;" title="NicoVideoStoryboardの設定変更">NicoVideo-<br>Storyboard設定</a></li>');
        var $panel  = $('<div id="NicoVideoStoryboardSettingPanel" />');//.addClass('open');
        //var $button = $('<button class="toggleSetting playerBottomButton">設定</botton>');

        //$button.on('click', function(e) {
        //  e.stopPropagation(); e.preventDefault();
        //  $panel.toggleClass('open');
        //});

        var config = this.config, eventDispatcher = this._eventDispatcher;
        $menu.find('a').on('click', function() { $panel.toggleClass('open'); });

        var __tpl__ = (function() {/*
          <div class="panelHeader">
          <h1 class="windowTitle">NicoVideoStoryboardの設定</h1>
            <p>設定はリロード後に反映されます</p>
          <button class="close" title="閉じる">×</button>
          </div>
          <div class="panelInner">
            <div class="item" data-setting-name="frameSkip" data-menu-type="radio">
              <h3 class="itemTitle">フレームスキップ</h3>
              <p>数字が小さいほど滑らかですが、重くなります</p>
              <label><input type="radio" value="0" >0</label>
              <label><input type="radio" value="1" >1</label>
              <label><input type="radio" value="2" >2</label>
              <label><input type="radio" value="3" >3</label>
              <label><input type="radio" value="4" >4</label>
              <label><input type="radio" value="5" >5</label>
              <label><input type="radio" value="6" >6</label>
              <label><input type="radio" value="7" >7</label>
              <label><input type="radio" value="8" >8</label>
              <label><input type="radio" value="9" >9</label>
            </div>
            <div class="item" data-setting-name="demoMode" data-menu-type="radio">
              <h3 class="itemTitle">デモモード</h3>
              <p>連続再生時、サムネイルが無い動画をスキップします</p>
              <label><input type="radio" value="true" > ON</label>
              <label><input type="radio" value="false"> OFF</label>
            </div>
          </div>
        */}).toString().match(/[^]*\/\*([^]*)\*\/\}$/)[1].replace(/\{\*/g, '/*').replace(/\*\}/g, '*/');
        $panel.html(__tpl__);
        $panel.find('.item').on('click', function(e) {
          var $this = $(this);
          var settingName = $this.attr('data-setting-name');
          var value = JSON.parse($this.find('input:checked').val());
          var currentValue = config.get(settingName);
          if (currentValue !== value) {
            console.log('%cseting-name: ' + settingName, 'background: cyan', 'value', value);
            config.set(settingName, value);
            eventDispatcher.dispatchEvent('NicoVideoStoryboard.config.' + settingName, value);
          }
        }).each(function(e) {
          var $this = $(this);
          var settingName = $this.attr('data-setting-name');
          var value = config.get(settingName);
          $this.addClass(settingName);
          $this.find('input').attr('name', settingName).val([JSON.stringify(value)]);
        });
        $panel.find('.close').click(function() {
          $panel.removeClass('open');
        });


        $('#siteHeaderRightMenuFix').after($menu);
        $('body').append($panel);
      },
      _onThumbnailInfoLoad: function(info) {
        if (
          info.status !== 'ok' &&
          this.config.get('demoMode') === true &&
          this._watchController.playlist.isContinuous()
          ) {
          this._watchController.playlist.playNext();
        }
      }

    });


    //======================================
    //======================================
    //======================================

    require(['watchapp/model/WatchInfoModel'], function(WatchInfoModel) {
      var watchInfoModel = WatchInfoModel.getInstance();
      if (watchInfoModel.initialized) {
        console.log('%c initialize', 'background: lightgreen;');
        initializeWatchController();
        window.NicoVideoStoryboard.initialize();
      } else {
        var onReset = function() {
          watchInfoModel.removeEventListener('reset', onReset);
          window.setTimeout(function() {
            watchInfoModel.removeEventListener('reset', onReset);
            console.log('%c initialize', 'background: lightgreen;');
            initializeWatchController();
            window.NicoVideoStoryboard.initialize();
          }, 0);
        };
        watchInfoModel.addEventListener('reset', onReset);
      }
    });
  };

  // クロスドメインでのgetflv情報の通信用
  var flapi = function() {
    if (window.name.indexOf('getflvLoader') < 0 ) { return; }

    var resp    = document.documentElement.textContent;
    var origin  = 'http://' + location.host.replace(/^.*?\./, 'www.');

    try {
      parent.postMessage(JSON.stringify({
          id: 'NicoVideoStoryboard',
          type: 'getflv',
          body: {
            url: location.href,
            info: resp
          }
        }),
        origin);
    } catch (e) {
      alert(e);
      console.log('err', e);
    }
  };

  var xmlHttpRequest = function(options) {
    try {
      var req = new XMLHttpRequest();
      var method = options.method || 'GET';
      req.onreadystatechange = function() {
        if (req.readyState === 4) {
          if (typeof options.onload === "function") options.onload(req);
        }
      };
      req.open(method, options.url, true);
      if (options.headers) {
        for (var h in options.headers) {
          req.setRequestHeader(h, options.headers[h]);
        }
      }

      req.send(options.data || null);
    } catch (e) {
      console.error(e);
    }
  };

  // Tampermonkeyでは、Content-type: text/xmlのページでスクリプトを実行できないため、
  // いったんxmlと同一ドメインにあるサムネイルを表示してそこ経由でスクリプトを起動する
  var smileapiForTampermonkey = function() {
    var origin  = 'http://' + location.host.replace(/^.*?\./, 'www.');
    var url     = location.hash.split('#')[1];

    xmlHttpRequest({
      url: url,
      onload: function(req) {
        var xml = req.responseText;
        parent.postMessage(JSON.stringify({
            id: 'NicoVideoStoryboard',
            type: 'storyboard',
            body: {
              url: url,
              xml: xml
            }
          }),
          origin);
      }
    });
  };

  // クロスドメインでのStoryboard情報の通信用
  var smileapi = function() {
    if (window.name.indexOf('StoryboardLoader') < 0 ) { return; }

    if (location.href.match(/\.nicovideo\.jp\/smile?i=/) >= 0) {
      return smileapiForTampermonkey();
    }

    var resp    = document.getElementsByTagName('smile');
    var origin  = 'http://' + location.host.replace(/^.*?\./, 'www.');
    var xml = '';

    if (resp.length > 0) {
      xml = resp[0].outerHTML;
    }

    try {
      parent.postMessage(JSON.stringify({
          id: 'NicoVideoStoryboard',
          type: 'storyboard',
          body: {
            url: location.href,
            xml: xml
          }
        }),
        origin);
    } catch (e) {
      console.log('err', e);
    }
  };



  var host = window.location.host || '';
  if (host === 'flapi.nicovideo.jp') {
    flapi();
  } else
  if (host.indexOf('smile-') >= 0) {
    smileapi();
  } else {
    window.require(['WatchApp'], function() {
      var script = document.createElement('script');
      script.id = 'NicoVideoStoryboard';
      script.setAttribute('type', 'text/javascript');
      script.setAttribute('charset', 'UTF-8');
      script.appendChild(document.createTextNode("(" + monkey + ")()"));
      document.body.appendChild(script);
    });
  }
})();