Greasy Fork is available in English.

Nico Nico Ranking NG

ニコニコ動画のランキングにNG機能を追加

От 02.10.2015. Виж последната версия.

// ==UserScript==
// @name Nico Nico Ranking NG
// @namespace http://userscripts.org/users/121129
// @description ニコニコ動画のランキングにNG機能を追加
// @match http://www.nicovideo.jp/ranking*
// @version 18
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_addStyle
// @license MIT License
// @noframes
// @run-at document-start
// ==/UserScript==

;(function() {
  'use strict'

  var array = [].slice.call.bind([].slice)
  var noop = function() {}
  var toUpperCase = ''.toUpperCase.call.bind(''.toUpperCase)
  var getter = function(propName) {
    return function() { return this[propName] }
  }
  var always = function(v) {
    return function() { return v }
  }
  var not = function(fn) {
    return function() { return !fn.apply(null, arguments) }
  }
  var curry = (function() {
    var applyOrRebind = function(func, arity, args) {
      var passed = args.concat(array(arguments, 3)).slice(0, arity)
      return arity === passed.length
             ? func.apply(null, passed)
             : applyOrRebind.bind(null, func, arity, passed)
    }
    return function(func) {
      return applyOrRebind.bind(null, func, func.length, [])
    }
  })()
  var eq = curry(function(a, b) { return a === b })
  var prop = curry(function(propName, obj) {
    return obj[propName]
  })
  var callMethod = curry(function(methodName, args, obj) {
    return obj[methodName].apply(obj, args)
  })
  var flip = function(fn) {
    return curry(function(a, b) { return fn.call(null, b, a) })
  }
  var include = curry(function(array, elem) {
    return array.indexOf(elem) >= 0
  })
  var includeUpperCase = curry(function(array, elem) {
    return include(array, elem.toUpperCase())
  })
  var compose = function() {
    var args = array(arguments)
    var first = args.pop()
    return function() {
      return args.reduceRight(function(v, f) {
        return f(v)
      }, first.apply(null, arguments))
    }
  }
  var removeAllChild = function(parent) {
    while (parent.firstChild) parent.removeChild(parent.firstChild)
  }
  var removeFromParent = function(elem) {
    var p = elem.parentNode
    if (p) p.removeChild(elem)
  }
  var ancestorAnchor = function(elem) {
    for (var a = elem; a; a = a.parentNode) if (a.tagName === 'A') return a
    return null
  }
  var elem = (function() {
    var setter = function(mapName) {
      return function() {
        if (arguments.length === 1) {
          var o = arguments[0] || {}
          Object.keys(o).forEach(function(k) { this[mapName][k] = o[k] }, this)
        } else if (arguments.length === 2) {
          this[mapName][arguments[0]] = arguments[1]
        }
        return this
      }
    }

    var Builder = function(tagName) {
      this.tagName = tagName
      this.attrMap = Object.create(null)
      this.cssMap = Object.create(null)
      this.handlers = []
      this.children = []
    }
    Builder.prototype.attr = setter('attrMap')
    Builder.prototype.css = setter('cssMap')
    Builder.prototype.on = function(type, handler, capture) {
      this.handlers.push({
        type: type,
        handler: handler,
        capture: Boolean(capture),
      })
      return this
    }
    Builder.prototype.add = function() {
      this.children = [].concat.apply(this.children, arguments)
      return this
    }
    Builder.prototype.new = function(doc) {
      doc = doc || document
      var result = this.tagName ? doc.createElement(this.tagName)
                                : doc.createDocumentFragment()
      elem.attr(result, this.attrMap)
      elem.css(result, this.cssMap)
      elem.add(result, this.children)
      this.handlers.forEach(function(h) {
        result.addEventListener(h.type, h.handler, h.capture)
      })
      return result
    }

    var elem = function(tagName) {
      return new Builder(tagName)
    }
    elem.attr = function(elem) {
      var l = arguments.length
      if (l === 3) {
        elem.setAttribute(arguments[1], arguments[2])
      } else if (l === 2) {
        var o = arguments[1] || {}
        Object.keys(o).forEach(function(k) {
          var v = o[k]
          if (['string', 'number'].indexOf(typeof v) >= 0) {
            elem.setAttribute(k, o[k])
          } else {
            elem[k] = Boolean(v)
          }
        })
      }
      return elem
    }
    elem.css = function(elem) {
      var l = arguments.length
      if (l === 3) {
        elem.style.setProperty(arguments[1], arguments[2], null)
      } else if (l === 2) {
        var o = arguments[1] || {}
        Object.keys(o).forEach(function(k) {
          elem.style.setProperty(k, o[k], null)
        })
      }
      return elem
    }
    elem.add = function(elem) {
      var children = [].concat.apply([], [].slice.call(arguments, 1))
      var d = elem.ownerDocument
      var f = d.createDocumentFragment()
      children.map(function(c) {
        return c.nodeType ? c : d.createTextNode(c)
      }).forEach(f.appendChild.bind(f))
      elem.appendChild(f)
      return elem
    }
    return elem
  })()
  var newStores = function() {
    var includeCaseInsensitive = function(array, elem) {
      return includeUpperCase(array.map(toUpperCase), elem)
    }
    var filterCaseInsensitive = function(array, elems) {
      return array.filter(not(includeUpperCase(elems.map(toUpperCase))))
    }
    var o = function(key, defVal, json, caseInsensitive) {
      return {
        get: GM_getValue,
        set: GM_setValue,
        clear: GM_deleteValue,
        key: key,
        defaultValue: defVal,
        json: json,
        include: caseInsensitive ? includeCaseInsensitive : undefined,
        filter: caseInsensitive ? filterCaseInsensitive : undefined,
      }
    }
    return {
      visitedMovieViewMode: new Store(o('visitedMovieViewMode', 'reduce')),
      visibleContributorType: new Store(o('visibleContributorType', 'all')),
      openNewWindow: new Store(o('openNewWindow', true)),
      useGetThumbInfo: new Store(o('useGetThumbInfo', true)),
      movieInfoTogglable: new Store(o('movieInfoTogglable', true)),
      seamlessRankingNumber: new Store(o('seamlessRankingNumber', true)),
      descriptionTogglable: new Store(o('descriptionTogglable', true)),
      requestingNext: new Store(o('requestingNext', true)),
      popupVisible: new Store(o('popupVisible', true)),
      movingUp: new Store(o('movingUp', true)),
      visitedMovieIds: new Store(o('visitedMovies', [], true)),
      ngMovieIds: new Store(o('ngMovies', [], true)),
      ngTitles: new Store(o('ngTitles', [], true, true)),
      ngTags: new Store(o('ngTags', [], true, true)),
      ngUserIds: new Store(o('ngUserIds', [], true)),
      ngChannelIds: new Store(o('ngChannelIds', [], true)),
    }
  }
  var updateMovies = function(movies, stores) {
    var visitedMovieIds = stores.visitedMovieIds.get()
    var ngMovieIds = stores.ngMovieIds.get()
    var ngTitles = stores.ngTitles.get()
    movies.forEach(function(movie) {
      movie.setVisitedIfInclude(visitedMovieIds)
      movie.setNgIdIfInclude(ngMovieIds)
      movie.setNgTitleIfInclude(ngTitles)
    })
  }
  var commonCssText = function() {
    return [
      '#nrn-config-button {',
      '  text-decoration: underline;',
      '  cursor: pointer;',
      '}',
      '.nrn-popup {',
      '  display: none;',
      '  position: absolute;',
      '  top: 10px;',
      '  right: 0px;',
      '  padding: 3px;',
      '  color: #999;',
      '  background-color: rgb(105, 105, 105);',
      '}',
      '.nrn-popup span {',
      '  color: white;',
      '}',
      '.nrn-popup span:hover {',
      '  text-decoration: underline;',
      '  cursor: pointer;',
      '}',
      '.nrn-display-none {',
      '  display: none;',
      '}',
      '.nrn-matched-ng-title {',
      '  color: white;',
      '  background-color: fuchsia;',
      '}',
      '.nrn-movie-info-container .nrn-movie-info-p {',
      '  margin-top: 4px;',
      '  line-height: 1.5em;',
      '}',
      '.nrn-tag-ng-button,',
      '.nrn-contributor-ng-button {',
      '  cursor: pointer;',
      '}',
    ].join('\n')
  }

  var Movie = (function() {

    var setIfIncludeId = function(targetProp, viewMethod) {
      return function(ids) {
        var b = include(ids, this._id)
        if (this[targetProp] !== b) {
          this[targetProp] = b
          this._view[viewMethod](this)
        }
      }
    }
    var setNgContributorIfInclude = function(ids) {
      var ng = include(ids, this._id)
      if (this._ng !== ng) {
        this._ng = ng
        this._view.updateNgContributor(this._movie)
      }
    }
    var equalArray = function(a1, a2) {
      if (a1.length !== a2.length) return false
      for (var i = 0; i < a1.length; i++)
        if (a1[i] !== a2[i]) return false
      return true
    }
    var compressU3000 = function(str) {
      return str.replace(/\u3000{2,}/g, '\u3000')
    }

    var Contributor = function(id, name, view, movie) {
      this._id = id
      this._name = name
      this._ng = false
      this._view = view
      this._movie = movie
    }
    Contributor.prototype.id = getter('_id')
    Contributor.prototype.name = getter('_name')
    Contributor.prototype.isNg = getter('_ng')
    Contributor.prototype.isUser = always(false)
    Contributor.prototype.isChannel = always(false)
    Contributor.prototype.isNull = function() {
      return this === Contributor.null
    }
    Contributor.prototype.setNgUserIfInclude = noop
    Contributor.prototype.setNgChannelIfInclude = noop
    Contributor.null = new Contributor(-1, '', null, null)

    var User = function() {
      Contributor.apply(this, arguments)
    }
    User.prototype = Object.create(Contributor.prototype)
    User.prototype.isUser = always(true)
    User.prototype.setNgUserIfInclude = setNgContributorIfInclude

    var Channel = function() {
      Contributor.apply(this, arguments)
    }
    Channel.prototype = Object.create(Contributor.prototype)
    Channel.prototype.isChannel = always(true)
    Channel.prototype.setNgChannelIfInclude = setNgContributorIfInclude

    var Movie = function(id, title, view) {
      this._id = id
      this._title = title
      this._view = view

      this._visited = false
      this._ngId = false
      this._ngTitle = false
      this._matchedNgTitle = ''
      this._tags = []
      this._ngTags = []
      this._error = ''
      this._description = ''
      this._contributor = Contributor.null
    }
    Movie.prototype.id = getter('_id')
    Movie.prototype.title = getter('_title')
    Movie.prototype.isVisited = getter('_visited')
    Movie.prototype.setVisitedIfInclude =
      setIfIncludeId('_visited', 'updateVisited')
    Movie.prototype.isNgId = getter('_ngId')
    Movie.prototype.setNgIdIfInclude = setIfIncludeId('_ngId', 'updateNgId')
    Movie.prototype.isNgTitle = getter('_ngTitle')
    Movie.prototype.matchedNgTitle = getter('_matchedNgTitle')
    Movie.prototype._matchedNgTitleIfInclude = function(ngTitles) {
      var matched = ngTitles.filter(
        includeUpperCase(this._title.toUpperCase()))
      return matched.length ? matched[0] : ''
    }
    Movie.prototype._viewUpdateMethodName = function(newMatchedNgTitle) {
      var newNgTitle = Boolean(newMatchedNgTitle)
      if (this._ngTitle !== newNgTitle) {
        return 'updateNgTitle'
      }
      if (this._ngTitle && newNgTitle
          && this._matchedNgTitle !== newMatchedNgTitle) {
        return 'updateMatchedNgTitle'
      }
      return ''
    }
    Movie.prototype.setNgTitleIfInclude = function(ngTitles) {
      var matchedNgTitle = this._matchedNgTitleIfInclude(ngTitles)
      var updateMethodName = this._viewUpdateMethodName(matchedNgTitle)
      this._ngTitle = Boolean(matchedNgTitle)
      this._matchedNgTitle = matchedNgTitle
      if (updateMethodName) this._view[updateMethodName](this)
    }
    Movie.prototype.isNG = function() {
      return this._ngId
          || this._ngTitle
          || Boolean(this._ngTags.length)
          || this._contributor.isNg()
    }
    Movie.prototype.getTags = getter('_tags')
    Movie.prototype.getNgTags = getter('_ngTags')
    Movie.prototype.setNgTagsIfMatch = function(ngTags) {
      var newNgTags = ngTags.filter(
        includeUpperCase(this._tags.map(toUpperCase)))
      if (!equalArray(this._ngTags, newNgTags)) {
        this._ngTags = newNgTags
        this._view.updateNgTags(this)
      }
    }
    Movie.prototype.hasNgTag = function(tag) {
      return this._ngTags.map(toUpperCase).some(eq(tag.toUpperCase()))
    }
    Movie.prototype.setNgUserIfInclude = function(ids) {
      this._contributor.setNgUserIfInclude(ids)
    }
    Movie.prototype.setNgChannelIfInclude = function(ids) {
      this._contributor.setNgChannelIfInclude(ids)
    }
    Movie.prototype.hasMovieInfo = function() {
      return Boolean(this._tags.length)
          || this._contributor !== Contributor.null
    }
    Movie.prototype.contributor = getter('_contributor')
    Movie.prototype._newContributorBy = function(thumbInfo) {
      var c = thumbInfo.contributor
      switch (c.type) {
      case 'user': return new User(c.id, c.name, this._view, this)
      case 'channel': return new Channel(c.id, c.name, this._view, this)
      }
    }
    Movie.prototype.getError = getter('_error')
    Movie.prototype.getDescription = getter('_description')
    Movie.prototype.setThumbInfo = function(thumbInfo) {
      if (thumbInfo.error) {
        this._error = thumbInfo.error
      } else {
        this._description = compressU3000(thumbInfo.description)
        this._tags = thumbInfo.tags
        this._contributor = this._newContributorBy(thumbInfo)
        if (this._title !== thumbInfo.title) {
          this.setNgTitleIfInclude([])
          this._title = thumbInfo.title
          this._view.updateTitle(this)
        }
      }
      this._view.updateThumbInfo(this)
    }
    Movie.prototype.setGetThumbInfoDone = function() {
      this._view.updateGetThumbInfoDone(this)
    }
    return Movie
  })()

  var Store = (function() {

    var defaultInclude = include
    var defaultFilter = function(array, elems) {
      return array.filter(not(include(elems)))
    }

    var Store = function(obj) {
      this._get = obj.get
      this._set = obj.set
      this._clear = obj.clear
      this._key = obj.key
      this._defaultValue = obj.json ? JSON.stringify(obj.defaultValue)
                                    : obj.defaultValue
      this._json = Boolean(obj.json)
      this._include = obj.include || defaultInclude
      this._filter = obj.filter || defaultFilter
      this._cache = null
    }
    Store.prototype.get = function() {
      var v = this._get(this._key, this._defaultValue)
      return this._cache = (this._json ? JSON.parse(v) : v)
    }
    Store.prototype.cacheOrGet = function() {
      return this._cache === null ? (this._cache = this.get()) : this._cache
    }
    Store.prototype._setValue = function(value) {
      this._set(this._key, this._json ? JSON.stringify(value) : value)
      this._cache = null
      return true
    }
    Store.prototype.set = function(value) {
      return value === this.get() ? false : this._setValue(value)
    }
    Store.prototype.clear = function() {
      this._clear(this._key)
      this._cache = null
    }
    Store.prototype.add = function(elem) {
      var array = this.get()
      if (this._include(array, elem)) return false
      return this._setValue(array.concat(elem))
    }
    Store.prototype.remove = function(/* ...elem */) {
      var oldArray = this.get()
      var newArray = this._filter(oldArray, [].concat.apply([], arguments))
      if (oldArray.length === newArray.length) return false
      return this._setValue(newArray)
    }
    return Store
  })()

  var ViewModeStore = function(store) {
    this._store = store
  }
  ViewModeStore.prototype.get = function() {
    return ViewMode.get(this._store.get())
  }
  ViewModeStore.prototype.set = function(viewMode) {
    this._store.set(viewMode.name())
  }

  var ObservableStore = function(store, postChange) {
    this._store = store
    this._postChange = postChange
  }
  ObservableStore.prototype.get = function() {
    return this._store.get()
  }
  ObservableStore.prototype.set = function(value) {
    if (this._store.set(value)) this._postChange(this)
  }
  ObservableStore.prototype.clear = function() {
    this._store.clear()
    this._postChange(this)
  }
  ObservableStore.prototype.add = function(elem) {
    if (this._store.add(elem)) {
      this._postChange(this)
      return true
    }
    return false
  }
  ObservableStore.prototype.remove = function(/* ...elem */) {
    var s = this._store
    if (s.remove.apply(s, arguments)) this._postChange(this)
  }

  var GetThumbInfo = (function() {

    var parseTags = function(tags) {
      return [].map.call(tags, prop('textContent'))
    }
    var contributor = function(rootElem, type, id, name) {
      return {
        type: type,
        id: parseInt(rootElem.querySelector(id).textContent, 10),
        name: rootElem.querySelector(name).textContent,
      }
    }
    var user = function(rootElem) {
      return contributor(rootElem
                       , 'user'
                       , 'thumb > user_id'
                       , 'thumb > user_nickname')
    }
    var channel = function(rootElem) {
      return contributor(rootElem
                       , 'channel'
                       , 'thumb > ch_id'
                       , 'thumb > ch_name')
    }
    var parseContributor = function(rootElem) {
      var userId = rootElem.querySelector('thumb > user_id')
      return userId ? user(rootElem) : channel(rootElem)
    }
    var parseThumbInfo = function(rootElem) {
      return {
        description: rootElem.querySelector('thumb > description').textContent,
        tags: parseTags(rootElem.querySelectorAll('thumb > tags > tag')),
        contributor: parseContributor(rootElem),
        title: rootElem.querySelector('thumb > title').textContent,
      }
    }
    var parseError = function(rootElem) {
      switch (rootElem.querySelector('error > code').textContent) {
      case 'DELETED': return {error: '削除された動画'}
      case 'NOT_FOUND': return {error: '見つからない、または無効な動画'}
      case 'COMMUNITY': return {error: 'コミュニティ限定動画'}
      }
    }
    var parseResText = function(resText) {
      var d = new DOMParser().parseFromString(resText, 'application/xml')
      var r = d.documentElement
      var status = r.getAttribute('status')
      switch (status) {
      case 'ok': return parseThumbInfo(r)
      case 'fail': return parseError(r)
      }
    }

    var GetThumbInfo = function(request, movies, stores, concurrent) {
      this._request = request
      this._movies = movies.slice()
      this._concurrent = concurrent
      this._stores = stores
      this._requestCount = 0
    }
    GetThumbInfo.prototype._onerror = function(movie, message) {
      this._requestCount--
      this._requestMovie()
      movie.setThumbInfo({error: message})
      movie.setGetThumbInfoDone()
    }
    GetThumbInfo.prototype._setNg = function(movie) {
      movie.setNgTagsIfMatch(this._stores.ngTags.cacheOrGet())
      movie.setNgUserIfInclude(this._stores.ngUserIds.cacheOrGet())
      movie.setNgChannelIfInclude(this._stores.ngChannelIds.cacheOrGet())
      movie.setNgTitleIfInclude(this._stores.ngTitles.cacheOrGet())
    }
    GetThumbInfo.prototype._onload = function(movie, res) {
      this._requestCount--
      this._requestMovie()
      if (res.status === 200) {
        var thumbInfo = parseResText(res.responseText)
        movie.setThumbInfo(thumbInfo)
        if (!thumbInfo.error) this._setNg(movie)
      } else {
        movie.setThumbInfo({error: res.statusText})
      }
      movie.setGetThumbInfoDone()
    }
    GetThumbInfo.prototype._requestMovie = function() {
      var m = this._movies.shift()
      if (!m) return
      this._request({
        method: 'GET',
        url: 'http://ext.nicovideo.jp/api/getthumbinfo/' + m.id(),
        timeout: 5000,
        onload: this._onload.bind(this, m),
        onerror: this._onerror.bind(this, m, 'エラー'),
        ontimeout: this._onerror.bind(this, m, 'タイムアウト'),
      })
      this._requestCount++
    }
    GetThumbInfo.prototype.request = function(movies) {
      ;[].push.apply(this._movies, movies || [])
      var space = this._concurrent - this._requestCount
      var c = Math.min(this._movies.length, space)
      for (var i = 0; i < c; i++) this._requestMovie()
    }
    return GetThumbInfo
  })()

  var Model = (function() {

    var movieUpdater = function(model, setter) {
      var flippedSetterCall = flip(setter.call.bind(setter))
      return function(store) {
        model._movies.forEach(flippedSetterCall(store.get()))
      }
    }

    var Model = function(movies, view, stores) {
      this._movies = movies
      this._view = view
      this._stores = stores
      this._ngMovieVisible = false
      this._useGetThumbInfo = stores.useGetThumbInfo
      this._requestingNext = stores.requestingNext
      this._popupVisible = stores.popupVisible
      this._visitedMovieViewMode =
        new ViewModeStore(
          new ObservableStore(stores.visitedMovieViewMode
                            , view.updateVisitedMovieViewMode.bind(view)))
      this._visibleContributorType =
        new ObservableStore(stores.visibleContributorType
                          , view.updateVisibleContributorType.bind(view))
      this._openNewWindow =
        new ObservableStore(stores.openNewWindow
                          , view.updateNewWindowOpen.bind(view))
      this._seamlessRankingNumber =
        new ObservableStore(stores.seamlessRankingNumber
                          , view.updateSeamlessRankingNumber.bind(view))
      this._movieInfoTogglable =
        new ObservableStore(stores.movieInfoTogglable
                          , view.updateMovieInfoTogglable.bind(view))
      this._descriptionTogglable =
        new ObservableStore(stores.descriptionTogglable
                          , view.updateDescriptionTogglable.bind(view))
      this._movingUp =
        new ObservableStore(stores.movingUp, view.updateMovingUp.bind(view))
      var p = Movie.prototype
      this._visitedMovieIds =
        new ObservableStore(stores.visitedMovieIds
                          , movieUpdater(this, p.setVisitedIfInclude))
      this._ngMovieIds =
        new ObservableStore(stores.ngMovieIds
                          , movieUpdater(this, p.setNgIdIfInclude))
      this._ngTitles =
        new ObservableStore(stores.ngTitles
                          , movieUpdater(this, p.setNgTitleIfInclude))
      this._ngTags =
        new ObservableStore(stores.ngTags
                          , movieUpdater(this, p.setNgTagsIfMatch))
      this._ngUserIds =
        new ObservableStore(stores.ngUserIds
                          , movieUpdater(this, p.setNgUserIfInclude))
      this._ngChannelIds =
        new ObservableStore(stores.ngChannelIds
                          , movieUpdater(this, p.setNgChannelIfInclude))
    }
    Model.prototype.movies = getter('_movies')
    Model.prototype.stores = getter('_stores')
    Model.prototype.movieById = function(id) {
      var a = this._movies.filter(compose(eq(id), callMethod('id', [])))
      return a.length ? a[0] : null
    }
    Model.prototype.isNgMovieVisible = getter('_ngMovieVisible')
    Model.prototype.setNgMovieVisible = function(ngMovieVisible) {
      if (this._ngMovieVisible !== ngMovieVisible) {
        this._ngMovieVisible = ngMovieVisible
        this._view.updateNgMovieVisible()
      }
    }
    Model.prototype.visitedMovieViewMode = getter('_visitedMovieViewMode')
    Model.prototype.visibleContributorType = getter('_visibleContributorType')
    Model.prototype.openNewWindow = getter('_openNewWindow')
    Model.prototype.useGetThumbInfo = getter('_useGetThumbInfo')
    Model.prototype.requestingNext = getter('_requestingNext')
    Model.prototype.popupVisible = getter('_popupVisible')
    Model.prototype.seamlessRankingNumber = getter('_seamlessRankingNumber')
    Model.prototype.movieInfoTogglable = getter('_movieInfoTogglable')
    Model.prototype.descriptionTogglable = getter('_descriptionTogglable')
    Model.prototype.movingUp = getter('_movingUp')
    Model.prototype.visitedMovieIds = getter('_visitedMovieIds')
    Model.prototype.ngMovieIds = getter('_ngMovieIds')
    Model.prototype.ngTitles = getter('_ngTitles')
    Model.prototype.ngTags = getter('_ngTags')
    Model.prototype.ngUserIds = getter('_ngUserIds')
    Model.prototype.ngChannelIds = getter('_ngChannelIds')
    Model.prototype._isVisible = function(movie) {
      return !movie.isNG() || this._ngMovieVisible
    }
    Model.prototype._isHiddenByVisitedHideMode = function(movie) {
      return movie.isVisited()
          && this.visitedMovieViewMode().get().isHideMode()
    }
    Model.prototype._isHiddenByContributorType = function(movie) {
      if (movie.contributor().isNull()) return false
      var t = this.visibleContributorType().get()
      return (t === 'channel' && !movie.contributor().isChannel())
          || (t === 'user' && !movie.contributor().isUser())
    }
    Model.prototype._isHidden = function(movie) {
      return !this._isVisible(movie)
          || this._isHiddenByVisitedHideMode(movie)
          || this._isHiddenByContributorType(movie)
    }
    Model.prototype._isReduced = function(movie) {
      return this._isVisible(movie)
          && movie.isVisited()
          && this.visitedMovieViewMode().get().isReduceMode()
    }
    Model.prototype.movieViewMode = function(movie) {
      if (this._isHidden(movie)) return new ViewMode.Hide()
      if (this._isReduced(movie)) return new ViewMode.Reduce()
      return new ViewMode.DoNothing()
    }
    Model.prototype.sortMoviesByVisible = function(movies) {
      var self = this
      return (movies || this._movies).map(function(movie, i) {
        return { rank: i, movie: movie }
      }).sort(function(a, b) {
        var aIsHidden = self._isHidden(a.movie)
        var bIsHidden = self._isHidden(b.movie)
        if (!aIsHidden && bIsHidden) return -1
        if (aIsHidden && !bIsHidden) return 1
        if (a.rank < b.rank) return -1
        if (a.rank > b.rank) return 1
        return 0
      }).map(prop('movie'))
    }
    Model.prototype.addMovies = function (movies) {
      this._movies = this._movies.concat(movies)
    }
    return Model
  })()

  var ViewMode = function() {}
  ViewMode.get = function(name) {
    switch (name) {
    case ViewMode.DoNothing.prototype.name(): return new ViewMode.DoNothing()
    case ViewMode.Reduce.prototype.name(): return new ViewMode.Reduce()
    case ViewMode.Hide.prototype.name(): return new ViewMode.Hide()
    default: throw new Error(name)
    }
  }
  ViewMode.prototype.isDoNothingMode = function() {
    return this instanceof ViewMode.DoNothing
  }
  ViewMode.prototype.isHideMode = function() {
    return this instanceof ViewMode.Hide
  }
  ViewMode.prototype.isReduceMode = function() {
    return this instanceof ViewMode.Reduce
  }
  ViewMode.prototype.hasSameName = function(viewMode) {
    return this.name() === viewMode.name()
  }

  ViewMode.DoNothing = function() {}
  ViewMode.DoNothing.prototype = Object.create(ViewMode.prototype)
  ViewMode.DoNothing.prototype.name = always('doNothing')
  ViewMode.DoNothing.prototype.restoreViewMode = noop
  ViewMode.DoNothing.prototype.setViewMode = noop

  ViewMode.Hide = function() {}
  ViewMode.Hide.prototype = Object.create(ViewMode.prototype)
  ViewMode.Hide.prototype.name = always('hide')
  ViewMode.Hide.prototype.restoreViewMode = function(movieView) {
    movieView.setVisible(true)
  }
  ViewMode.Hide.prototype.setViewMode = function(movieView) {
    movieView.setVisible(false)
  }

  ViewMode.Reduce = function() {}
  ViewMode.Reduce.prototype = Object.create(ViewMode.prototype)
  ViewMode.Reduce.prototype.name = always('reduce')
  ViewMode.Reduce.prototype.restoreViewMode = function(movieView) {
    var r = movieView.root
    r.classList.remove('nrn-reduce')
    this.restoreThumb(r.querySelector('.thumb'))
  }
  ViewMode.Reduce.prototype.setViewMode = function(movieView) {
    var r = movieView.root
    r.classList.add('nrn-reduce')
    this.halfThumb(r.querySelector('.thumb'))
  }
  ViewMode.Reduce.prototype.halfThumb = function(thumb) {
    if (thumb.style.width) {
      var w = this.srcThumbWidth = parseInt(thumb.style.width)
      var t = this.srcThumbMarginTop = parseInt(thumb.style.marginTop)
      thumb.style.width = parseInt(w / 2) + 'px'
      thumb.style.marginTop = parseInt(t / 2) + 'px'
    } else {
      thumb.style.width = '100%'
    }
  }
  ViewMode.Reduce.prototype.restoreThumb = function(thumb) {
    if (this.srcThumbWidth) {
      thumb.style.width = this.srcThumbWidth + 'px'
      thumb.style.marginTop = this.srcThumbMarginTop + 'px'
    } else {
      thumb.style.width = ''
    }
  }

  var MovieView = (function() {

    var newParagraph = function(doc) {
      return elem('p')
        .attr('class', 'font12 nrn-movie-info-p')
        .add([].slice.call(arguments, 1))
        .new(doc)
    }
    var newActionButton = function(klass, doc) {
      return elem('span').attr('class', klass).add('[+]').new(doc)
    }
    var OPEN_TOGGLE_TEXT = '開く▼'
    var CLOSE_TOGGLE_TEXT = '閉じる▲'
    var newMovieInfoToggleButton = function(doc) {
      return elem('span')
        .attr('class', 'count nrn-movie-info-toggle')
        .add(elem('span')
          .attr('class', 'value nrn-movie-info-toggle-button')
          .add(OPEN_TOGGLE_TEXT)
          .new(doc))
        .new(doc)
    }
    var newDescriptionToggleButton = function(doc) {
      return elem('span')
        .attr('class', 'nrn-desc-toggle-button')
        .add(OPEN_TOGGLE_TEXT)
        .new(doc)
    }
    var newDescriptionToggleP = function(toggle, doc) {
      return elem('p')
        .attr('class', 'nrn-desc-toggle-p')
        .add(toggle)
        .new(doc)
    }
    var decodeHtmlCharRef = (function() {
      var e = document.createElement('span')
      return function(text) {
        e.innerHTML = text
        return e.textContent
      }
    })()
    var newTagAnchor = function(tag, doc) {
      return elem('a')
        .attr({
          href: 'http://www.nicovideo.jp/tag/' + tag,
          target: '_blank',
          class: 'nrn-movie-tag-link',
        })
        .add(decodeHtmlCharRef(tag))
        .new(doc)
    }
    var newTagSpan = function(anchor, button) {
      return elem('span')
        .attr('class', 'nrn-movie-tag')
        .add(anchor, button)
        .new(anchor.ownerDocument)
    }
    var setTagActionAndStyle = function(tagView, ng) {
      tagView.anchor.classList[ng ? 'add' : 'remove']('nrn-movie-ng-tag-link')
      tagView.button.textContent = ng ? '[x]' : '[+]'
    }
    var newContributorA = function(doc) {
      return elem('a')
        .attr({'target': '_blank', 'class': 'nrn-contributor-link'})
        .new(doc)
    }

    var Description = function(movieView) {
      this.movieView = movieView
      this.expanded = false
      this.hasBeenSet = false
      this.p = elem('p').attr('class', 'nrn-desc-p').new(movieView.view.doc)
      this.toggle = newDescriptionToggleButton(movieView.view.doc)
      this.toggleP = newDescriptionToggleP(this.toggle, movieView.view.doc)
    }
    Description.prototype.container = function() {
      return this.movieView.root.querySelector('.itemDescription')
    }
    Description.prototype.isTruncated = function() {
      return this.container().textContent.slice(-3) === '...'
    }
    Description.prototype.set = function(description) {
      this.p.textContent = description
      var e = this.container()
      removeAllChild(e)
      e.classList.add('nrn-desc')
      e.appendChild(this.p)
      this.hasBeenSet = true
    }
    Description.prototype.addToggle = function() {
      this.container().appendChild(this.toggleP)
    }
    Description.prototype.removeToggle = function() {
      removeFromParent(this.toggleP)
    }
    Description.prototype.setExpanded = function(expanded) {
      this.expanded = expanded
      this.p.classList[expanded ? 'add' : 'remove']('nrn-desc-p-close')
      this.toggleP
        .classList[expanded ? 'remove' : 'add']('nrn-desc-toggle-p-open')
      this.toggle.textContent = expanded ? CLOSE_TOGGLE_TEXT : OPEN_TOGGLE_TEXT
    }
    Description.prototype.linkify = (function() {
      var re = /(sm|so|nm|co|ar|im|lv|mylist\/|watch\/|user\/)(?:\d+)/g
      var type2href = {
        sm: 'http://www.nicovideo.jp/watch/',
        so: 'http://www.nicovideo.jp/watch/',
        nm: 'http://www.nicovideo.jp/watch/',
        co: 'http://com.nicovideo.jp/community/',
        ar: 'http://ch.nicovideo.jp/article/',
        im: 'http://seiga.nicovideo.jp/seiga/',
        lv: 'http://live.nicovideo.jp/watch/',
        'mylist/': 'http://www.nicovideo.jp/',
        'watch/': 'http://www.nicovideo.jp/',
        'user/': 'http://www.nicovideo.jp/',
      }
      return function() {
        var descElem = this.hasBeenSet ? this.p : this.container()
        var text = descElem.textContent
        var builder = elem()
        var lastIndex = 0
        for (var r; r = re.exec(text);) {
          builder.add([
            text.slice(lastIndex, r.index),
            elem('a')
              .attr({target: '_blank', href: type2href[r[1]] + r[0]})
              .add(r[0])
              .new(),
          ])
          lastIndex = re.lastIndex
        }
        var df = builder.add(text.slice(lastIndex)).new()
        df.normalize()
        removeAllChild(descElem)
        descElem.appendChild(df)
      }
    })()

    var MovieInfo = function(movieView) {
      this.movieView = movieView
      this.visible = true
      var d = movieView.view.doc
      this.toggle = newMovieInfoToggleButton(d)
      this.container = elem('div')
        .attr('class', 'nrn-movie-info-container').new(d)
      this.tagP = newParagraph(d)

      this.contributorType = elem('span').add('ユーザー: ').new()
      this.contributorA = newContributorA(d)
      this.ngContributorButton =
        newActionButton('nrn-contributor-ng-button', d)
      this.contributorP = newParagraph(d
                                     , this.contributorType
                                     , this.contributorA
                                     , this.ngContributorButton)

      this.tag2view = Object.create(null)
    }
    MovieInfo.prototype.setParagraphsVisible = function(visible) {
      ;[this.tagP, this.contributorP].forEach(function(p) {
        p.classList[visible ? 'remove' : 'add']('nrn-display-none')
      })
    }
    MovieInfo.prototype.getItemDataElem = function() {
      return this.movieView.root.querySelector('.itemData')
    }
    MovieInfo.prototype.updateVisible = function() {
      var b = this.toggle
      b.childNodes[0].textContent = this.visible ? CLOSE_TOGGLE_TEXT
                                                 : OPEN_TOGGLE_TEXT
      this.setParagraphsVisible(this.visible)
    }
    MovieInfo.prototype.setVisible = function(visible) {
      this.visible = visible
      this.updateVisible()
    }
    MovieInfo.prototype.addToggle = function() {
      var b = this.toggle
      if (!b.parentNode) {
        var e = this.getItemDataElem()
        e.classList.add('nrn-movie-info-toggle-container')
        e.appendChild(b)
      }
      this.setVisible(false)
    }
    MovieInfo.prototype.removeToggle = function() {
      var b = this.toggle
      if (b.parentNode) {
        var e = this.getItemDataElem()
        e.removeChild(b)
        e.classList.remove('nrn-movie-info-toggle-container')
      }
      this.setVisible(true)
    }
    MovieInfo.prototype.getItemContent = function() {
      return this.movieView.root.querySelector('.itemContent')
    }
    MovieInfo.prototype.addContainerToItemContent = function() {
      var d = this.container
      if (!d.parentNode) this.getItemContent().appendChild(d)
    }
    MovieInfo.prototype.setTags = function(tags) {
      var d = this.movieView.view.doc
      var t2v = this.tag2view
      var p = this.tagP
      tags.forEach(function(tag) {
        var a = newTagAnchor(tag, d)
        var b = newActionButton('nrn-tag-ng-button', d)
        elem.add(p, newTagSpan(a, b), ' ')
        t2v[tag] = { anchor: a, button: b }
      })
      this.container.appendChild(p)
      this.addContainerToItemContent()
    }
    MovieInfo.prototype.updateByNgTags = function(ngTags) {
      var t2v = this.tag2view
      var tags = Object.keys(t2v)
      var upperNgTags = ngTags.map(toUpperCase)
      tags.filter(not(includeUpperCase(upperNgTags))).forEach(function(tag) {
        setTagActionAndStyle(t2v[tag], false)
      })
      tags.filter(includeUpperCase(upperNgTags)).forEach(function(tag) {
        setTagActionAndStyle(t2v[tag], true)
      })
    }
    MovieInfo.prototype.setContributor = function(contributor) {
      this.contributorType.textContent = contributor.isUser()
                                         ? 'ユーザー: ' : 'チャンネル: '
      var preHref = contributor.isUser()
                    ? 'http://www.nicovideo.jp/user/'
                    : 'http://ch.nicovideo.jp/channel/ch'
      this.contributorA.href = preHref + contributor.id()
      this.contributorA.textContent = contributor.name()
      this.container.appendChild(this.contributorP)
      this.addContainerToItemContent()
    }
    MovieInfo.prototype.updateContributorByNg = function(ng) {
      var method = ng ? 'add' : 'remove'
      this.contributorA.classList[method]('nrn-ng-contributor-link')
      this.ngContributorButton.textContent = ng ? '[x]' : '[+]'
    }

    var Title = function(a) {
      this.a = a
    }
    Title.prototype.update = function(movie) {
      var a = this.a
      if (!movie.isNgTitle()) {
        a.textContent = a.textContent
        return
      }
      removeAllChild(a)
      var m = movie.matchedNgTitle()
      var t = movie.title()
      var i = t.toUpperCase().indexOf(m.toUpperCase())
      if (i !== 0) elem.add(a, t.substring(0, i))
      elem.add(a,
        elem('span')
          .attr('class', 'nrn-matched-ng-title')
          .add(t.substring(i, i + m.length))
          .new(a.ownerDocument))
      if (i + m.length !== t.length) elem.add(a, t.substring(i + m.length))
    }
    Title.prototype.setLineThrough = function(lineThrough) {
      this.a.classList[lineThrough ? 'add' : 'remove']('nrn-ng-movie-title')
    }
    Title.prototype.setTitle = function(title) {
      this.a.textContent = title
    }

    var MovieView = function(view, titleAnchor, root) {
      this.view = view
      this.root = root
      this.title = new Title(titleAnchor)
      this.viewMode = new ViewMode.DoNothing()
      this.movieInfo = this.newMovieInfo(this)
      this.description = new Description(this)
      this.errorElem = this.newErrorElem(view.doc)
    }
    MovieView.MovieInfo = MovieInfo
    MovieView.prototype.newMovieInfo = function () {
      return new MovieInfo(this)
    }
    MovieView.prototype.newErrorElem = function(doc) {
      return elem('li').attr('class', 'count').css('color', 'red').new(doc)
    }
    MovieView.prototype.setVisible = function(visible) {
      this.root.classList[visible ? 'remove' : 'add']('nrn-display-none')
    }
    MovieView.prototype.getMovieDataElem = function() {
      return this.root.querySelector('ul.list')
    }
    MovieView.prototype.setViewModeIfDiff = function(viewMode) {
      if (this.viewMode.hasSameName(viewMode)) return false
      this.viewMode.restoreViewMode(this)
      viewMode.setViewMode(this)
      this.viewMode = viewMode
      return true
    }
    MovieView.prototype.updateViewMode = function(movie) {
      var m = this.view.model.movieViewMode(movie)
      if (this.setViewModeIfDiff(m)) this.view.requestLoadingLazyImages()
    }
    MovieView.prototype.updateVisited = function(movie) {
      this.updateViewMode(movie)
      this.view.updatePopupMenu(movie)
    }
    MovieView.prototype.updateNgId = function(movie) {
      this.updateViewMode(movie)
      this.title.setLineThrough(movie.isNgId())
      this.view.updatePopupMenu(movie)
    }
    MovieView.prototype.updateMatchedNgTitle = function(movie) {
      this.title.update(movie)
    }
    MovieView.prototype.updateNgTitle = function(movie) {
      this.updateViewMode(movie)
      this.updateMatchedNgTitle(movie)
      this.view.updatePopupMenu(movie)
    }
    MovieView.prototype._isMovieInfoTogglable = function () {
      return this.view.model.movieInfoTogglable().get()
    }
    MovieView.prototype.updateTags = function(movie) {
      this.movieInfo.setTags(movie.getTags())
      if (this._isMovieInfoTogglable()) {
        this.movieInfo.addToggle(movie)
      }
    }
    MovieView.prototype.updateNgTags = function(movie) {
      this.updateViewMode(movie)
      this.movieInfo.updateByNgTags(movie.getNgTags())
    }
    MovieView.prototype.updateDescription = function(movie) {
      var d = this.description
      if (d.isTruncated()) {
        d.set(movie.getDescription())
        var togglable = this.view.model.descriptionTogglable().get()
        d.setExpanded(!togglable)
        if (togglable) d.addToggle()
      }
      d.linkify()
    }
    MovieView.prototype.updateError = function(movie) {
      this.errorElem.textContent = movie.getError()
      this.getMovieDataElem().appendChild(this.errorElem)
    }
    MovieView.prototype.updateContributor = function(movie) {
      this.movieInfo.setContributor(movie.contributor())
      if (this._isMovieInfoTogglable()) {
        this.movieInfo.addToggle(movie)
      }
    }
    MovieView.prototype.updateNgContributor = function(movie) {
      this.updateViewMode(movie)
      this.movieInfo.updateContributorByNg(movie.contributor().isNg())
    }
    MovieView.prototype.updateThumbInfo = function(movie) {
      if (movie.getError()) {
        this.updateError(movie)
      } else {
        this.updateTags(movie)
        this.updateContributor(movie)
        this.updateDescription(movie)
        this.updateViewMode(movie)
      }
    }
    MovieView.prototype.updateTitle = function(movie) {
      this.title.setTitle(movie.title())
    }
    MovieView.prototype.updateGetThumbInfoDone = function() {
      this.root.classList.add('nrn-getThumbInfoDone')
    }
    return MovieView
  })()

  var LazyImageLoader = (function() {
    var INTERVAL = 125
    var LazyImageLoader = function(doc) {
      this._doc = doc
      this._requested = false
      this._requestedInInterval = false
    }
    LazyImageLoader.prototype._lazyImages = function() {
      return [].slice.call(this._doc.querySelectorAll('img.thumb.jsLazyImage'))
    }
    LazyImageLoader.prototype._lazyImagesInView = function() {
      return this._lazyImages().filter(this._isInView.bind(this))
    }
    LazyImageLoader.prototype._isInView = function(elem) {
      var r = elem.getBoundingClientRect()
      if (!(r.width && r.height)) return false
      var h = this._doc.defaultView.innerHeight
      return (r.top >= 0 && r.top < h) || (r.bottom > 0 && r.bottom <= h)
    }
    LazyImageLoader.prototype._load = function() {
      this._lazyImagesInView().forEach(function(img) {
        img.src = img.dataset.original
        img.dataset.original = ''
        img.classList.remove('jsLazyImage')
      })
    }
    LazyImageLoader.prototype._loadIfRequestedInInterval = function() {
      if (this._requestedInInterval) {
        this._requestedInInterval = false
        this._load()
        setTimeout(this._loadIfRequestedInInterval.bind(this), INTERVAL)
      } else {
        this._requested = false
      }
    }
    LazyImageLoader.prototype.request = function() {
      if (this._requested) {
        this._requestedInInterval = true
        return
      }
      this._requested = true
      setTimeout(this._load.bind(this))
      setTimeout(this._loadIfRequestedInInterval.bind(this), INTERVAL)
    }
    return LazyImageLoader
  })()

  var GinzaView = (function() {
    var bindClassContains = function(elem) {
      return elem.classList.contains.bind(elem.classList)
    }
    var checkbox = function(doc, clickCallback, checked) {
      return elem('input')
        .attr({type: 'checkbox', checked: checked})
        .on('click', clickCallback)
        .new(doc)
    }
    var popupMenuButton = function(text, klass, doc) {
      return elem('span').attr('class', klass).add(text).new(doc)
    }
    var movieNgButtonText = function(ng) {
      return ng ? 'NG解除' : 'NG登録'
    }
    var newMovieNgButton = function(doc) {
      return popupMenuButton(movieNgButtonText(false)
                           , 'nrn-movie-ng-button'
                           , doc)
    }
    var titleNgButtonText = function(ng) {
      return ng ? 'NGタイトル削除' : 'NGタイトル追加'
    }
    var newTitleNgButton = function(doc) {
      return popupMenuButton(titleNgButtonText(false)
                           , 'nrn-title-ng-button'
                           , doc)
    }
    var visitButtonText = function(ng) {
      return ng ? '未閲覧' : '閲覧済み'
    }
    var newVisitButton = function(doc) {
      return popupMenuButton(visitButtonText(false), 'nrn-visit-button', doc)
    }
    var newContributorTypeSelect = function(doc, changeListener) {
      return elem('select')
        .add([
          elem('option').attr({value: 'all', selected: true}).add('全部').new(doc),
          elem('option').attr('value', 'user').add('ユーザー').new(doc),
          elem('option').attr('value', 'channel').add('チャンネル').new(doc),
        ])
        .on('change', changeListener)
        .new(doc)
    }
    var newVisitedMovieViewModeSelect = function(doc, changeListener) {
      return elem('select')
        .add([
          elem('option').attr({value: 'reduce', selected: true}).add('縮小').new(doc),
          elem('option').attr('value', 'hide').add('非表示').new(doc),
          elem('option').attr('value', 'doNothing').add('通常表示').new(doc),
        ])
        .on('change', changeListener)
        .new(doc)
    }
    var newPopupMenu = function(doc) {
      return elem('div')
        .attr('class', 'nrn-popup')
        .add([
          newVisitButton(doc),
          ' | ',
          newMovieNgButton(doc),
          ' | ',
          newTitleNgButton(doc),
        ])
        .new(doc)
    }
    var containsClass = curry(function(className, elem) {
      return elem.classList.contains(className)
    })
    var contributionDay = function (nicoChartDay) {
      var r = /(\d{4})年(\d{2})月(\d{2})日 (\d{2}):(\d{2}):\d{2}/
        .exec(nicoChartDay)
      return `${r[1]}/${r[2]}/${r[3]} ${r[4]}:${r[5]}`
    }
    var hourlyRegExp = /^\/ranking\/(fav|view|res|mylist)\/hourly\/all/

    var GinzaView = function(doc) {
      doc = doc || document
      this.doc = doc
      this.visitedMovieViewModeSelect = newVisitedMovieViewModeSelect(
        doc, this._setVisitedMovieViewMode.bind(this))
      this.contributorTypeSelect = newContributorTypeSelect(
        doc, this._setVisibleContributorType.bind(this))
      this.ngMovieVisibleCheckbox =
        checkbox(doc, this._setNgMovieVisible.bind(this))
      this.configButton = this._newConfigButton()
      this.popup = newPopupMenu(doc)
      this.idToMovieView = Object.create(null)
      this.lazyImageLoader = new LazyImageLoader(doc)
    }
    GinzaView.prototype.setModel = function(model) {
      this.model = model
      this.updateVisitedMovieViewMode()
      this.updateNewWindowOpen()
      this.updateMovieInfoTogglable()
      this.updateSeamlessRankingNumber()
      this.updateVisibleContributorType()
    }
    GinzaView.prototype._newConfigButton = function() {
      return elem('span')
        .attr('id', 'nrn-config-button')
        .on('click', this.showConfigDialog.bind(this))
        .add('設定')
        .new(this.doc)
    }
    GinzaView.prototype._getMovieImages = function() {
      return array(this.doc.querySelectorAll('img.thumb'))
    }
    GinzaView.prototype._getMovieAnchors = function() {
      return this._getMovieImages()
        .map(prop('parentNode'))
        .concat(this._getWatchAnchors())
    }
    GinzaView.prototype._getWatchAnchors = function(root) {
      root = root || this.doc
      return array(root.querySelectorAll('p.itemTitle.ranking a'))
    }
    GinzaView.prototype._isMovieRoot = function(elem) {
      return ['item', 'videoRanking'].every(bindClassContains(elem))
    }
    GinzaView.prototype._getMovieRoot = function(child) {
      for (var e = child; e && e.classList; e = e.parentNode) {
        if (this._isMovieRoot(e)) return e
      }
      return null
    }
    GinzaView.prototype._newMovieView = function(titleAnchor) {
      return new MovieView(this, titleAnchor, this._getMovieRoot(titleAnchor))
    }
    GinzaView.prototype.movieIdInHRef = function(href) {
      var execResult = /watch\/([^/?]+)/.exec(href)
      return execResult ? execResult[1] : null
    }
    GinzaView.prototype.getMovies = function(root) {
      var result = [], i2v = this.idToMovieView
      this._getWatchAnchors(root).forEach(function(a) {
        var id = this.movieIdInHRef(a.getAttribute('href'))
        var movieView = i2v[id] = this._newMovieView(a)
        result.push(new Movie(id, a.textContent, movieView))
      }, this)
      return result
    }
    GinzaView.prototype._newAddedController = function() {
      return this.doc.createDocumentFragment()
    }
    GinzaView.prototype._newControllers = function() {
      return elem('div')
        .add([
          elem('label').add('閲覧済みの動画を', this.visitedMovieViewModeSelect).new(this.doc),
          ' | ',
          elem('label').add('投稿者', this.contributorTypeSelect).new(this.doc),
          ' | ',
          elem('label').add(this.ngMovieVisibleCheckbox, ' NG動画を表示')
            .new(this.doc),
          ' | ',
          this._newAddedController(),
          this.configButton,
        ])
        .new(this.doc)
    }
    GinzaView.prototype.addControllers = function() {
      var mainDiv = this.doc.querySelector('div.contentBody.video')
      mainDiv.insertBefore(this._newControllers(), mainDiv.firstChild)
    }
    GinzaView.prototype.updateNewWindowOpen = function() {
      var f = this.model.openNewWindow().get()
              ? callMethod('setAttribute', ['target', '_blank'])
              : callMethod('removeAttribute', ['target'])
      this._getMovieAnchors().forEach(f)
    }
    GinzaView.prototype.updateViewMode = function(movie) {
      this.idToMovieView[movie.id()].updateViewMode(movie)
    }
    GinzaView.prototype.updateVisitedMovieViewMode = function() {
      this.visitedMovieViewModeSelect.value =
        this.model.visitedMovieViewMode().get().name()
      this.model.movies().forEach(this.updateViewMode, this)
    }
    GinzaView.prototype.updateNgMovieVisible = function() {
      this.ngMovieVisibleCheckbox.checked = this.model.isNgMovieVisible()
      this.model.movies().forEach(this.updateViewMode, this)
    }
    GinzaView.prototype.updateVisibleContributorType = function() {
      this.contributorTypeSelect.value =
        this.model.visibleContributorType().get()
      this.model.movies().forEach(this.updateViewMode, this)
    }
    GinzaView.prototype.toggleDescriptionExpanded = function(event) {
      var movieId = this.movieIdByComponent(event.target)
      var d = this.idToMovieView[movieId].description
      d.setExpanded(!d.expanded)
    }
    GinzaView.prototype.updateDescriptionTogglable = function() {
      var togglable = this.model.descriptionTogglable().get()
      var methodName = togglable ? 'addToggle' : 'removeToggle'
      var id2view = this.idToMovieView
      Object.keys(id2view)
        .map(flip(prop)(id2view))
        .filter(compose(prop('hasBeenSet'), prop('description')))
        .forEach(function(view) {
          view.description[methodName]()
          view.description.setExpanded(!togglable)
        })
    }
    GinzaView.prototype._addMovieInfoToggleButton = function(movie) {
      this.idToMovieView[movie.id()].movieInfo.addToggle()
    }
    GinzaView.prototype._removeMovieInfoToggleButton = function(movie) {
      this.idToMovieView[movie.id()].movieInfo.removeToggle()
    }
    GinzaView.prototype.toggleMovieInfoVisible = function(event) {
      var movieId = this.movieIdByComponent(event.target)
      var v = this.idToMovieView[movieId]
      v.movieInfo.setVisible(!v.movieInfo.visible)
    }
    GinzaView.prototype.updateMovieInfoTogglable = function() {
      var f = this.model.movieInfoTogglable().get()
              ? this._addMovieInfoToggleButton
              : this._removeMovieInfoToggleButton
      this.model.movies()
        .filter(callMethod('hasMovieInfo', []))
        .forEach(f, this)
    }
    GinzaView.prototype._setVisitedMovieViewMode = function() {
      this.model.visitedMovieViewMode()
        .set(ViewMode.get(this.visitedMovieViewModeSelect.value))
    }
    GinzaView.prototype._setVisibleContributorType = function() {
      this.model.visibleContributorType()
        .set(this.contributorTypeSelect.value)
    }
    GinzaView.prototype._setNgMovieVisible = function() {
      this.model.setNgMovieVisible(this.ngMovieVisibleCheckbox.checked)
    }
    GinzaView.prototype.showConfigDialog = function() {
      ConfigDialog.show(this.model)
    }
    GinzaView.prototype.requestLoadingLazyImages = function() {
      this.lazyImageLoader.request()
    }
    GinzaView.prototype.movieIdByComponent = function(elem) {
      var r = this._getMovieRoot(elem)
      return r ? r.dataset.id : ''
    }
    GinzaView.prototype.movieIdByPopup = function() {
      return this.movieIdByComponent(this.popup)
    }
    GinzaView.prototype.tagByTagNgButton = function(tagNgButton) {
      return tagNgButton.previousSibling.textContent
    }
    GinzaView.prototype.isMovieNgButton = containsClass('nrn-movie-ng-button')
    GinzaView.prototype.isTitleNgButton = containsClass('nrn-title-ng-button')
    GinzaView.prototype.isVisitButton = containsClass('nrn-visit-button')
    GinzaView.prototype.isTagNgButton = containsClass('nrn-tag-ng-button')
    GinzaView.prototype.isContributorNgButton =
      containsClass('nrn-contributor-ng-button')
    GinzaView.prototype.isTitleAnchor = function(elem) {
      var a = ancestorAnchor(elem)
      return Boolean(a)
          && Boolean(a.parentNode)
          && ['itemTitle', 'ranking'].every(bindClassContains(a.parentNode))
    }
    GinzaView.prototype.isThumbAnchor = function(elem) {
      var a = ancestorAnchor(elem)
      return Boolean(a) && a.matches(
        '.videoList01Wrap > .itemThumbBox > .itemThumb > a.itemThumbWrap')
    }
    GinzaView.prototype.isMovieInfoToggle =
      containsClass('nrn-movie-info-toggle-button')
    GinzaView.prototype.isDescriptionToggle =
      containsClass('nrn-desc-toggle-button')
    GinzaView.prototype.contentBody = function() {
      return this.doc.querySelector('div.contentBody.video.videoList01')
    }
    GinzaView.prototype.updatePopupMenu = function(movie) {
      var p = this.popup
      if (!p.parentNode) return
      if (this.movieIdByComponent(p) !== movie.id()) return
      p.childNodes[0].textContent = visitButtonText(movie.isVisited())
      p.childNodes[2].textContent = movieNgButtonText(movie.isNgId())
      p.childNodes[4].textContent = titleNgButtonText(movie.isNgTitle())
    }
    GinzaView.prototype.addPopupMenuTo = function(elem) {
      this._getMovieRoot(elem).appendChild(this.popup)
      this.updatePopupMenu(this.model.movieById(this.movieIdByComponent(elem)))
    }
    GinzaView.prototype.removePopupMenu = function() {
      removeFromParent(this.popup)
    }
    GinzaView.prototype.updateSeamlessRankingNumber = function () {
      var b = this.model.seamlessRankingNumber().get()
      var e = this.doc.getElementById('nrn-seamlessRankingNumberStyle')
      if (b && !e) {
        var s = this.doc.createElement('style')
        s.id = 'nrn-seamlessRankingNumberStyle'
        s.textContent = this._getSeamlessRankingNumberCssText()
        this.doc.head.appendChild(s)
      } else if (!b && e) {
        e.remove()
      }
    }
    GinzaView.prototype._convertNicoChartTime = function(t) {
      var r = /((\d+)時間)?((\d{1,2})分)?((\d{1,2})秒)?/.exec(t)
      if (!(r && (r[1] || r[3] || r[5]))) return t
      var result = ''
      if (r[1]) result += r[2] + ':'
      if (r[3]) {
        result += (r[1] && r[4].length === 1 ? '0' : '') + r[4]
      } else {
        result += r[1] ? '00' : '0'
      }
      result += ':'
      if (r[5]) {
        result += (r[6].length === 1 ? '0' : '') + r[6]
      } else {
        result += '00'
      }
      return result
    }
    GinzaView.prototype.createMovieRoots = function (objs) {
      var div = this.doc.createElement('div')
      div.innerHTML = objs.map(function (o) {
        var id = this.movieIdInHRef(o.url)
        return `<li class="item videoRanking nrn-fromNicoChart" data-video-item data-enable-uad="1" data-id="${id}">
  <div class="rankingNumWrap">
    <p class="rankingNum">${o.rank}</p>
    <p class="rankingPt">+${o.point}</p>
  </div>
  <div data-video-comments style="display: none;">
      <p class="adComment" data-video-comments-inner></p>
  </div>

  <div class="videoList01Wrap">
      <p class="itemTime${o.fresh ? ' new' : ''}"> <span>${contributionDay(o.contributionDay)}</span> 投稿</p>
    <div class="itemThumbBox"><div class="itemThumb" data-video-thumbnail data-id="${id}"><a href="/watch/${id}" class="itemThumbWrap" data-link
    data-href="/watch/${id}"><img src="http://res.nimg.jp/images/noimage.png" title="" alt="" class="noImage"><img class="jsLazyImage thumb" src="http://res.nimg.jp/images/1x1.gif" data-original="${o.thumbURL}" alt="${o.title}" data-thumbnail></a></div><span class="videoLength">${this._convertNicoChartTime(o.movieLength)}</span></div>

  </div>

  <div class="itemContent">
    <p class="itemTitle ranking">
      <a title="${o.title}" href="watch/${id}" data-href="watch/${id}">${o.title}</a>
    </p>
    <div class="wrap">
      <p class="itemDescription ranking">${o.description}</p>
            <p class="itemComment ranking">${o.comment}</p>
          </div>

    <div class="itemData">
      <ul class="list">
        <li class="count view">再生<span class="value">${o.viewCount}</span></li>
        <li class="count comment">コメ<span class="value">${o.resCount}</span></li>
        <li class="count mylist">マイ<span class="value"><a href="/mylistcomment/video/${id}">${o.mylistCount}</a></span></li>
        <li class="count ads" style="display: none;" data-uad-point-outer>宣伝<span class="value"><a href="http://uad.nicovideo.jp/ads/?vid=so27132526&video_rank" data-uad-point>0</a></span></li>
      </ul>
    </div>
  </div>
</li>`
      }, this).join('')
      return div
    }
    GinzaView.prototype._getSeamlessRankingNumberCssText = function () {
      var e = this.doc.querySelector('.rankingNum')
      var n = e ? parseInt(e.textContent) - 1 : 0
      return `body {
  counter-increment: ranking ${n};
}
.video .item.videoRanking {
  counter-increment: ranking;
}
.videoList01 .item.videoRanking .rankingNumWrap .rankingNum {
  font-size: 0;
}
.videoList01 .item.videoRanking .rankingNumWrap .rankingNum::after {
  content: counter(ranking, decimal);
  font-size: 40px;
  line-height: 1.3;
}`
    }
    GinzaView.prototype.getCssText = function () {
      return commonCssText() + '\n' + [
        '.item.videoRanking:hover .nrn-popup {',
        '  display: inherit;',
        '}',
        '.nrn-reduce .rankingPt,',
        '.nrn-reduce .itemTime,',
        '.nrn-reduce .wrap,',
        '.nrn-reduce .itemData,',
        '.nrn-reduce .nrn-movie-info-container {',
        '  display: none;',
        '}',
        '.nrn-reduce .rankingNum {',
        '  font-size: 150%;',
        '}',
        '.videoList01 .nrn-reduce .videoList01Wrap {',
        '  width: 80px;',
        '}',
        '.videoList01 .nrn-reduce .itemContent .itemTitle.ranking {',
        '  width: auto;',
        '}',
        '.video .nrn-reduce .itemThumbBox,',
        '.video .nrn-reduce .itemThumbBox .itemThumb,',
        '.video .nrn-reduce .itemThumbBox .itemThumb .itemThumbWrap {',
        '  width: 80px;',
        '  height: 45px;',
        '}',
        '.nrn-contributor-link {',
        '  color: rgb(51, 51, 51);',
        '}',
        '.nrn-ng-contributor-link {',
        '  color: white;',
        '  background-color: fuchsia;',
        '}',
        '.nrn-movie-tag-link {',
        '  color: rgb(51, 51, 51);',
        '}',
        '.nrn-movie-ng-tag-link {',
        '  color: white;',
        '  background-color: fuchsia;',
        '}',
        '.nrn-desc-toggle-button {',
        '  cursor: pointer;',
        '  text-decoration: underline;',
        '}',
        '.nrn-desc-toggle-p {',
        '  text-align: right;',
        '}',
        '.nrn-desc-toggle-p-open {',
        '  position: absolute;',
        '  top: 0;',
        '  right: 0;',
        '}',
        '.nrn-desc-p {',
        '  width: 400px;',
        '  height: 14px;',
        '}',
        '.nrn-desc-p.nrn-desc-p-close {',
        '  width: 440px;',
        '  height: auto;',
        '}',
        '.videoList01 .itemContent .itemDescription.ranking.nrn-desc {',
        '  height: auto;',
        '  position: relative;',
        '}',
        '.nrn-movie-info-toggle {',
        '  position: absolute;',
        '  top: 0;',
        '  right: 0;',
        '}',
        '.video .itemData .count .value.nrn-movie-info-toggle-button {',
        '  cursor: pointer;',
        '  text-decoration: underline;',
        '  padding: 0;',
        '}',
        '.nrn-movie-info-toggle-container {',
        '  position: relative;',
        '  width: 440px;',
        '}',
        '.nrn-movie-tag {',
        '  white-space: nowrap;',
        '  margin-right: 0.5em;',
        '}',
        '.nrn-ng-movie-title {',
        '  text-decoration: line-through;',
        '}',
      ].join('\n') + `
.item.videoRanking.nrn-fromNicoChart .videoList01Wrap {
  text-align: center;
}
.item.videoRanking.nrn-fromNicoChart .itemThumbBox {
  display: inline-block;
}
.item.videoRanking.nrn-fromNicoChart .itemThumbBox,
.item.videoRanking.nrn-fromNicoChart .itemThumbBox .itemThumb,
.item.videoRanking.nrn-fromNicoChart .itemThumbBox .itemThumb .itemThumbWrap,
.item.videoRanking.nrn-fromNicoChart .itemThumbBox .itemThumb .itemThumbWrap .thumb {
  height: 100px;
  width: 130px;
}
.item.videoRanking.nrn-fromNicoChart .itemThumbBox .itemThumb .itemThumbWrap .thumb {
  max-height: 100px;
}
.item.videoRanking.nrn-fromNicoChart.nrn-reduce .videoList01Wrap {
  margin-left: 15px;
}
.item.videoRanking.nrn-fromNicoChart.nrn-reduce .itemThumbBox,
.item.videoRanking.nrn-fromNicoChart.nrn-reduce .itemThumbBox .itemThumb,
.item.videoRanking.nrn-fromNicoChart.nrn-reduce .itemThumbBox .itemThumb .itemThumbWrap,
.item.videoRanking.nrn-fromNicoChart.nrn-reduce .itemThumbBox .itemThumb .itemThumbWrap .thumb {
  height: 50px;
  width: 80px;
}
.item.videoRanking {
  visibility: hidden;
}
.item.videoRanking.nrn-getThumbInfoDone {
  visibility: inherit;
}
`
    }
    GinzaView.prototype.isHourlyAll = function () {
      return hourlyRegExp.test(this.doc.location.pathname)
    }
    GinzaView.prototype.getSortType = function () {
      var r = hourlyRegExp.exec(this.doc.location.pathname)
      if (r && r[1]) return r[1] === 'fav' ? 'all' : r[1]
      throw new Error(r && r[1])
    }
    GinzaView.prototype.requestToNicoChart = function (getThumbInfo, from) {
      from = from || 101
      var progress = elem('p').add(from + ' 位以降を取得中...').new(this.doc)
      this.contentBody().appendChild(progress)
      new NicoChart(GM_xmlhttpRequest, function (objs) {
        progress.remove()
        var div = this.createMovieRoots(objs.filter(function (o) {
          return !this.idToMovieView[this.movieIdInHRef(o.url)]
        }, this))
        var movies = this.getMovies(div)
        updateMovies(movies, this.model.stores())
        this.model.addMovies(movies)
        var target = this.doc.querySelector('.contentBody.video > .list')
        array(div.childNodes).forEach(function (c) {
          target.appendChild(c)
        })
        this.updateNewWindowOpen()
        if (getThumbInfo) {
          getThumbInfo.request(this.model.sortMoviesByVisible(movies))
        } else {
          movies.forEach(callMethod('setGetThumbInfoDone', []))
        }
        if (from === 101) {
          this.requestToNicoChart(getThumbInfo, 201)
        }
      }.bind(this), function (message) {
        progress.style.color = 'red'
        progress.innerHTML =
          `<a href="http://www.nicochart.jp/" target=_blank>ニコニコチャート</a>からの取得に失敗しました(${message})`
      }).request({type: this.getSortType(), from: from})
    }
    GinzaView.prototype.updateMovingUp = noop
    GinzaView.prototype.moveUp = noop
    return GinzaView
  })()

  var MatrixView = (function (_super) {
    var colToRows = function(idToMovieView) {
      return Object.keys(idToMovieView).map(function(id) {
        return idToMovieView[id]
      }).reduce(function(o, v) {
        var a = o[v.sourceCol]
        if (a) a.push(v); else o[v.sourceCol] = [v]
        return o
      }, {})
    }
    var visibleComparator = function(a, b) {
      var av = a.isVisible()
      var bv = b.isVisible()
      if (av && !bv) return -1
      if (!av && bv) return 1
      if (a.sourceRow < b.sourceRow) return -1
      if (a.sourceRow > b.sourceRow) return 1
      return 0
    }
    var sourceRowComparator = function(a, b) {
      if (a.sourceRow < b.sourceRow) return -1
      if (a.sourceRow > b.sourceRow) return 1
      return 0
    }
    var rowToCols = function(colToRows) {
      return Object.keys(colToRows).sort().map(function(col) {
        return colToRows[col]
      }).reduce(function(o, rows) {
        return rows.reduce(function(o, r, i) {
          var a = o[i]
          if (a) a.push(r); else o[i] = [r]
          return o
        }, o)
      }, [])
    }
    var currentRowComparator = function(a, b) {
      if (a.currentRow() < b.currentRow()) return -1
      if (a.currentRow() > b.currentRow()) return 1
      return 0
    }
    var diffRowToCols = function(current, fresh) {
      var result = []
      for (var i = 0; i < current.length; i++) {
        var cols = current[i]
        var diff = []
        for (var j = 0; j < cols.length; j++) {
          var f = fresh[i][j]
          if (cols[j].root === f.root) {
            diff.push(null)
          } else {
            diff.push(f)
          }
        }
        result.push(diff)
      }
      return result
    }

    var MatrixView = function () {
      _super.apply(this, arguments)
      this.movingUpCheckbox = elem('input')
        .attr('type', 'checkbox')
        .on('change', this._setMovingUp.bind(this))
        .new(this.doc)
      this._movingUpRequested = false
    }
    MatrixView.prototype._newMovingUpCheckbox = function() {
      return elem('input').attr('type', 'checkbox').new(this.doc)
    }
    MatrixView.is = function (location) {
      return location.pathname.startsWith('/ranking/matrix')
    }
    MatrixView.prototype = Object.create(_super.prototype)
    MatrixView.prototype.setModel = function(model) {
      _super.prototype.setModel.call(this, model)
      this.movingUpCheckbox.checked = model.movingUp().get()
    }
    MatrixView.prototype.contentBody = function() {
      return this.doc.querySelector('.column.main')
    }
    MatrixView.prototype._getWatchAnchors = function() {
      return array(this.doc.querySelectorAll('.itemTitle > a'))
    }
    MatrixView.prototype._isMovieRoot = function(elem) {
      return elem.tagName === 'TD'
    }
    MatrixView.prototype._newMovieView = function(root) {
      return new MatrixMovieView(this, root, this._getMovieRoot(root))
    }
    MatrixView.prototype.addControllers = function() {
      var c = this._newControllers()
      c.style.marginTop = '10px'
      var b = this.contentBody()
      b.insertBefore(c, b.firstChild)
    }
    MatrixView.prototype.isTitleAnchor = function(elem) {
      var a = ancestorAnchor(elem)
      return Boolean(a)
          && Boolean(a.parentNode)
          && a.parentNode.classList.contains('itemTitle')
    }
    MatrixView.prototype.isThumbAnchor = function(elem) {
      var a = ancestorAnchor(elem)
      return Boolean(a) && a.classList.contains('itemThumbWrap')
    }
    MatrixView.prototype.getCssText = function () {
      return commonCssText() + '\n' + [
        'td:hover .nrn-popup {',
        '  display: inherit;',
        '  z-index: 9999;',
        '}',
        '.top20 .bg_grade_0 a.nrn-contributor-link:link {',
        '  color: black !important;',
        '}',
        '.top20 .bg_grade_0 a.nrn-contributor-link:link:hover {',
        '  background-color: initial;',
        '}',
        '.top20 .bg_grade_0 a.nrn-ng-contributor-link:link {',
        '  color: white !important;',
        '  background-color: fuchsia;',
        '}',
        '.top20 .bg_grade_0 a.nrn-movie-tag-link:link {',
        '  color: black !important;',
        '}',
        '.top20 .bg_grade_0 a.nrn-movie-tag-link:link:hover {',
        '  background-color: initial;',
        '}',
        '.top20 .bg_grade_0 a.nrn-movie-ng-tag-link:link {',
        '  color: white !important;',
        '  background-color: fuchsia;',
        '}',
        '.nrn-movie-info-toggle-button {',
        '  cursor: pointer;',
        '  text-decoration: underline;',
        '}',
        'table.top20 tr td .nrn-movie-info-toggle-container {',
        '  background-position: top;',
        '}',
        '.nrn-movie-info-toggle {',
        '  display: block;',
        '  text-align: right;',
        '  margin-top: 5px;',
        '}',
        '.nrn-movie-info-container .nrn-movie-info-p {',
        '  text-align: left;',
        '}',
        '.nrn-movie-tag {',
        '  display: block;',
        '}',
        '.nrn-tag-ng-button,',
        '.nrn-contributor-ng-button {',
        '  white-space: nowrap;',
        '}',
        'td[data-video-item].nrn-reduce .itemThumbBox,',
        'td[data-video-item].nrn-reduce .itemThumbBox .itemThumb {',
        '  width: 50px;',
        '  height: 38px;',
        '  margin: 0;',
        '}',
        '.top20 .itemTitle a.nrn-ng-movie-title {',
        '  text-decoration: line-through;',
        '}',
        'td[data-video-item] {',
        '  position: relative;',
        '  visibility: hidden;',
        '}',
        'td[data-video-item].nrn-getThumbInfoDone {',
        '  visibility: inherit;',
        '}',
        'td[data-video-item].nrn-getThumbInfoDone.nrn-hidden {',
        '  visibility: hidden;',
        '}',
        '.nrn-hidden > .nrn-movie-info-container,',
        '.nrn-hidden > .nrn-movie-info-toggle {',
        '  display: none;',
        '}',
      ].join('\n')
    }
    MatrixView.prototype.updateMovieInfoTogglable = noop
    MatrixView.prototype.isHourlyAll = always(false)
    MatrixView.prototype._newAddedController = function() {
      return elem()
        .add([
          elem('label').add(this.movingUpCheckbox, '上に詰める').new(this.doc),
          ' | ',
        ])
        .new(this.doc)
    }
    MatrixView.prototype._setMovingUp = function() {
      this.model.movingUp().set(this.movingUpCheckbox.checked)
    }
    MatrixView.prototype.updateMovingUp = function() {
      if (this.model.movingUp().get()) {
        this.moveUp()
      } else {
        this.unmoveUp()
      }
    }
    MatrixView.prototype._sortedRowToCols = function(comparator) {
      var c2r = colToRows(this.idToMovieView)
      Object.keys(c2r).forEach(function(c) { c2r[c].sort(comparator) })
      return rowToCols(c2r)
    }
    MatrixView.prototype._addToMatrixTable = function() {
      var doc = this.doc
      var rows = doc.querySelector('.top20 table').rows
      return function(cols, i) {
        var row = rows[1 + i]
        cols.forEach(function(col, i) {
          if (!col) return
          if (col.root.parentNode) {
            col.root.parentNode.replaceChild(doc.createElement('td'), col.root)
          }
          row.replaceChild(col.root, row.cells[i + 1])
        })
      }
    }
    MatrixView.prototype._relocate = function(comparator) {
      var current = this._sortedRowToCols(currentRowComparator)
      var fresh = this._sortedRowToCols(comparator)
      diffRowToCols(current, fresh).forEach(this._addToMatrixTable())
    }
    MatrixView.prototype.moveUp = function() {
      this._relocate(visibleComparator)
    }
    MatrixView.prototype.unmoveUp = function() {
      this._relocate(sourceRowComparator)
    }
    MatrixView.prototype.requestMovingUp = function() {
      if (this._movingUpRequested) return
      this._movingUpRequested = true
      setTimeout(function() {
        this._movingUpRequested = false
        if (this.model.movingUp().get()) this.moveUp()
      }.bind(this))
    }
    return MatrixView
  })(GinzaView)

  var MatrixMovieView = (function(_super) {
    var MatrixMovieInfo = function(movieView) {
      _super.MovieInfo.call(this, movieView)
    }
    MatrixMovieInfo.prototype = Object.create(_super.MovieInfo.prototype)
    MatrixMovieInfo.prototype.getItemDataElem = function() {
      return this.movieView.root
    }
    MatrixMovieInfo.prototype.getItemContent = function() {
      return this.movieView.root
    }

    var MatrixMovieView = function(view, titleAnchor, root) {
      _super.call(this, view, titleAnchor, root)
      this.sourceRow = root.parentNode.rowIndex
      this.sourceCol = root.cellIndex
    }
    MatrixMovieView.MatrixMovieInfo = MatrixMovieInfo
    MatrixMovieView.prototype = Object.create(_super.prototype)
    MatrixMovieView.prototype.newMovieInfo = function () {
      return new MatrixMovieInfo(this)
    }
    MatrixMovieView.prototype.newErrorElem = function(doc) {
      return elem('div').css('color', 'red').new(doc)
    }
    MatrixMovieView.prototype.isVisible = function() {
      return !this.root.classList.contains('nrn-hidden')
    }
    MatrixMovieView.prototype.setVisible = function(visible) {
      this.root.classList[visible ? 'remove' : 'add']('nrn-hidden')
      this.view.requestMovingUp()
    }
    MatrixMovieView.prototype.getMovieDataElem = function() {
      return this.root
    }
    MatrixMovieView.prototype.currentRow = function() {
      return this.root.parentNode.rowIndex
    }
    MatrixMovieView.prototype._isMovieInfoTogglable = always(true)
    MatrixMovieView.prototype.updateDescription = noop
    return MatrixMovieView
  })(MovieView)

  var NicoChart = (function() {
    var url = function (o) {
      var t = o.type === 'all' ? '' : o.type
      var p = o.from === 101 ? '' : 'page=2&'
      return `http://now.nicochart.jp/hourly/${t}?${p}rank=-101`
    }
    var parseVideoInfo = function (e) {
      var t = function (selector) {
        var n = e.querySelector(selector)
        return n ? n.textContent : ''
      }
      return {
        point: e.parentNode.querySelector('.video-chart .point').textContent,
        contributionDay: e.querySelector('.first-retrieve').firstChild.nodeValue,
        movieLength: t('.length'),
        viewCount: t('.view em'),
        resCount: t('.res em'),
        mylistCount: t('.mylist em'),
        url: e.parentNode.querySelector('.thumbnail-image a').href,
        thumbURL: e.parentNode.querySelector('.thumbnail-image a img').title,
        title: t('.title'),
        description: t('.description-summary'),
        comment: t('.last-res-body'),
        fresh: Boolean(e.querySelector('.first-retrieve.new')),
      }
    }
    var parse = function (text, from) {
      var d = new DOMParser().parseFromString(text, 'text/html')
      var infos = d.querySelectorAll('#result .video-info')
      if (infos.length === 0) {
        throw new Error('"#result .video-info" query return empty')
      }
      return array(infos)
        .map(parseVideoInfo)
        .map(function (o) {
          o.rank = from++
          return o
        })
    }
    var NicoChart = function(request, ok, fail) {
      this._request = request
      this._ok = ok
      this._fail = fail
    }
    NicoChart.prototype._onload = function (from, r) {
      if (r.status === 200) {
        try {
          this._ok(parse(r.responseText, from))
        } catch (e) {
          console.log(e)
          this._fail(e.message)
        }
      } else {
        this._fail(r.status + ' ' + r.statusText)
      }
    }
    NicoChart.prototype._onerror = function (message) {
      this._fail(message)
    }
    NicoChart.prototype.request = function (o) {
      if (['all', 'view', 'res', 'mylist'].indexOf(o.type) === -1) {
        throw new Error('o.type: ' + o.type)
      }
      if ([101, 201].indexOf(o.from) === -1) {
        throw new Error('o.from: ' + o.from)
      }
      this._request({
        method: 'GET',
        timeout: 30000,
        url: url(o),
        onload: this._onload.bind(this, o.from),
        onerror: this._onerror.bind(this, 'エラー'),
        ontimeout: this._onerror.bind(this, 'タイムアウト'),
      })
    }
    return NicoChart
  })()

  var ConfigDialog = (function() {

    var opt = function(val, text, selected) {
      return elem('option').attr({value: val, selected: selected}).add(text)
    }
    var action = function(targetOption) {
      var noEmptyStr = function(v) { return v !== '' }
      var isPositiveInt = function(v) {
        var i = parseInt(v)
        return !isNaN(i) && i > 0
      }
      var toInt = function(v) { return parseInt(v) }
      var errMessage = '1以上の整数を入力して下さい。\n'
      return {
        'ng-movie-id': {
          get: function(model) { return model.ngMovieIds().get() },
          add: function(model, val) { return model.ngMovieIds().add(val) },
          remove: function(model, vals) { model.ngMovieIds().remove(vals) },
          removeAll: function(model) { model.ngMovieIds().clear() },
          valid: noEmptyStr,
          errMessage: '',
          url: function(v) { return 'http://www.nicovideo.jp/watch/' + v },
        },
        'ng-title': {
          get: function(model) { return model.ngTitles().get() },
          add: function(model, val) { return model.ngTitles().add(val) },
          remove: function(model, vals) { model.ngTitles().remove(vals) },
          removeAll: function(model) { model.ngTitles().clear() },
          valid: noEmptyStr,
          errMessage: '',
          url: function(v) { return 'http://www.nicovideo.jp/search/' + v },
        },
        'ng-tag': {
          get: function(model) { return model.ngTags().get() },
          add: function(model, val) { return model.ngTags().add(val) },
          remove: function(model, vals) { model.ngTags().remove(vals) },
          removeAll: function(model) { model.ngTags().clear() },
          valid: noEmptyStr,
          errMessage: '',
          url: function(v) { return 'http://www.nicovideo.jp/tag/' + v },
        },
        'ng-user-id': {
          get: function(model) { return model.ngUserIds().get() },
          add: function(model, val) {
            return model.ngUserIds().add(parseInt(val))
          },
          remove: function(model, vals) {
            model.ngUserIds().remove(vals.map(toInt))
          },
          removeAll: function(model) { model.ngUserIds().clear() },
          valid: isPositiveInt,
          errMessage: errMessage,
          url: function(v) { return 'http://www.nicovideo.jp/user/' + v },
        },
        'ng-channel-id': {
          get: function(model) { return model.ngChannelIds().get() },
          add: function(model, val) {
            return model.ngChannelIds().add(parseInt(val))
          },
          remove: function(model, vals) {
            model.ngChannelIds().remove(vals.map(toInt))
          },
          removeAll: function(model) { model.ngChannelIds().clear() },
          valid: isPositiveInt,
          errMessage: errMessage,
          url: function(v) { return 'http://ch.nicovideo.jp/ch' + v },
        },
        'visited-movie-id': {
          get: function(model) { return model.visitedMovieIds().get() },
          add: function(model, val) {
            return model.visitedMovieIds().add(val)
          },
          remove: function(model, vals) {
            model.visitedMovieIds().remove(vals)
          },
          removeAll: function(model) { model.visitedMovieIds().clear() },
          valid: noEmptyStr,
          errMessage: '',
          url: function(v) { return 'http://www.nicovideo.jp/watch/' + v },
        },
      }[targetOption.value]
    }
    var newBackground = function(zIndex) {
      return elem('div')
        .css({
          'background-color': 'black',
          opacity: '0.5',
          'z-index': String(zIndex),
          position: 'fixed',
          top: '0px',
          left: '0px',
          width: '100%',
          height: '100%',
        })
        .new()
    }
    var initCheckbox = function(checkbox, checked, changeListener) {
      checkbox.checked = checked
      checkbox.addEventListener('change', changeListener)
    }
    var byId = function(id) {
      return function() { return this.doc.getElementById(id) }
    }

    var ConfigDialog = function(model, doc) {
      this.model = model
      this.doc = doc
      this.background = newBackground(ConfigDialog.Z_INDEX - 1)
      this.iframe = null

      this.initListSelect()
      this.getTargetSelect()
        .addEventListener('change', this.updateList.bind(this))
      this.getAddButton()
        .addEventListener('click', this.addPromptResult.bind(this))
      this.getRemoveButton()
        .addEventListener('click', this.removeSelectedItems.bind(this))
      this.getRemoveAllButton()
        .addEventListener('click', this.removeAll.bind(this))
      this.getOpenButton()
        .addEventListener('click', this.openSelectedItems.bind(this))
      this.getCloseButton()
        .addEventListener('click', this.remove.bind(this))
      this.updateButtonDisabled()
      initCheckbox(this.getNewWindowOpenCheckbox()
                 , this.model.openNewWindow().get()
                 , this.updateNewWindowOpen.bind(this))
      initCheckbox(this.getUseGetThumbInfoCheckbox()
                 , this.model.useGetThumbInfo().get()
                 , this.updateUseGetThumbInfo.bind(this))
      initCheckbox(this.getSeamlessRankingNumberCheckbox()
                 , this.model.seamlessRankingNumber().get()
                 , this.updateSeamlessRankingNumber.bind(this))
      initCheckbox(this.getRequestingNextCheckbox()
                 , this.model.requestingNext().get()
                 , this.updateRequestingNext.bind(this))
      initCheckbox(this.getMovieInfoTogglableCheckbox()
                 , this.model.movieInfoTogglable().get()
                 , this.updateMovieInfoTogglable.bind(this))
      initCheckbox(this.getDescriptionTogglableCheckbox()
                 , this.model.descriptionTogglable().get()
                 , this.updateDescriptionTogglable.bind(this))
      initCheckbox(this.getPopupVisibleCheckbox()
                 , this.model.popupVisible().get()
                 , this.updatePopupVisible.bind(this))
    }
    ConfigDialog.Z_INDEX = 10000
    ConfigDialog.srcdoc = `<!doctype html>
<html><head><style>
  html {
    margin: 0 auto;
    max-width: 30em;
    height: 100%;
    line-height: 1.5em;
  }
  body {
    height: 100%;
    margin: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .dialog {
    overflow: auto;
    padding: 8px;
    background-color: white;
  }
  p {
    margin: 0;
  }
  .listButtonsWrap {
    display: flex;
  }
  .listButtonsWrap .list {
    flex: auto;
  }
  .listButtonsWrap .list select {
    width: 100%;
  }
  .listButtonsWrap .buttons {
    flex: none;
    display: flex;
    flex-direction: column;
  }
  .listButtonsWrap .buttons input {
    margin-bottom: 5px;
  }
  .sideComment {
    margin-left: 2em;
  }
  .dialogBottom {
    text-align: center;
  }
  .scriptInfo {
    text-align: right;
  }
</style></head><body>
  <div class=dialog>
    <p><select id=target>
      <option value=ng-movie-id>NG動画ID</option>
      <option value=ng-title selected>NGタイトル</option>
      <option value=ng-tag>NGタグ</option>
      <option value=ng-user-id>NGユーザーID</option>
      <option value=ng-channel-id>NGチャンネルID</option>
      <option value=visited-movie-id>閲覧済み動画ID</option>
    </select></p>
    <div class=listButtonsWrap>
      <p class=list><select multiple size=10 id=list></select></p>
      <p class=buttons>
        <input type=button value=追加 id=addButton>
        <input type=button value=削除 disabled id=removeButton>
        <input type=button value=全削除 disabled id=removeAllButton>
        <input type=button value=開く disabled id=openButton>
      </p>
    </div>
    <p><label><input type=checkbox id=newWindowOpen>動画を別窓で開く</label></p>
    <p><label><input type=checkbox id=useGetThumbInfo>動画情報を取得する</label></p>
    <p><label><input type=checkbox id=seamlessRankingNumber>表示されている動画で順位を数える</label></p>
    <p><label><input type=checkbox id=requestingNext>カテゴリ合算毎時ランキングの 101 位以降を取得する</label></p>
    <p class=sideComment><small>取得元: <a target=_blank href=http://www.nicochart.jp/>ニコニコチャート</a></small></p>
    <p><label><input type=checkbox id=popupVisible>ポップアップを表示する</label></p>
    <fieldset>
      <legend>表示・非表示の切り替えボタン</legend>
      <p><label><input type=checkbox id=movieInfoTogglable>タグ、ユーザー、チャンネル</label></p>
      <p><label><input type=checkbox id=descriptionTogglable>動画説明</label></p>
    </fieldset>
    <p class=dialogBottom><input type=button value=閉じる id=closeButton></p>
    <p class=scriptInfo><small><a href=https://greasyfork.org/ja/scripts/880-nico-nico-ranking-ng target=_blank>Nico Nico Ranking NG</a></small></p>
  </div>
</body></html>`
    ConfigDialog.show = function(model, callback) {
      var f = document.createElement('iframe')
      f.srcdoc = ConfigDialog.srcdoc
      f.addEventListener('load', function() {
        var d = new ConfigDialog(model, f.contentDocument)
        d.setup(f)
        if (callback) callback(d)
      })
      document.body.appendChild(f)
    }
    ConfigDialog.prototype.getNewWindowOpenCheckbox = byId('newWindowOpen')
    ConfigDialog.prototype.getUseGetThumbInfoCheckbox = byId('useGetThumbInfo')
    ConfigDialog.prototype.getSeamlessRankingNumberCheckbox = byId('seamlessRankingNumber')
    ConfigDialog.prototype.getRequestingNextCheckbox = byId('requestingNext')
    ConfigDialog.prototype.getMovieInfoTogglableCheckbox = byId('movieInfoTogglable')
    ConfigDialog.prototype.getDescriptionTogglableCheckbox = byId('descriptionTogglable')
    ConfigDialog.prototype.getPopupVisibleCheckbox = byId('popupVisible')
    ConfigDialog.prototype.getAddButton = byId('addButton')
    ConfigDialog.prototype.getRemoveButton = byId('removeButton')
    ConfigDialog.prototype.getRemoveAllButton = byId('removeAllButton')
    ConfigDialog.prototype.getOpenButton = byId('openButton')
    ConfigDialog.prototype.getCloseButton = byId('closeButton')
    ConfigDialog.prototype.getTargetSelect = byId('target')
    ConfigDialog.prototype.getListSelect = byId('list')
    ConfigDialog.prototype.initListSelect = function() {
      var l = this.getListSelect()
      elem.add(l, this.model.ngTitles().get().map(function(t) {
        return opt(t, t).new(this.doc)
      }, this))
      l.addEventListener('change', this.updateButtonDisabled.bind(this))
    }
    ConfigDialog.prototype.setup = function(iframe) {
      document.body.appendChild(this.background)
      this.iframe = elem.css(iframe, {
        position: 'fixed',
        top: '0',
        left: '0',
        width: '100%',
        height: '100%',
        'z-index': ConfigDialog.Z_INDEX,
      })
    }
    ConfigDialog.prototype.remove = function() {
      var f = this.iframe
      if (f && f.parentNode) f.parentNode.removeChild(f)
      var b = this.background
      if (b.parentNode) b.parentNode.removeChild(b)
    }
    ConfigDialog.prototype.selectedTargetOption = function() {
      return this.getTargetSelect().options[this.getTargetSelect().selectedIndex]
    }
    ConfigDialog.prototype.addPromptResult = function() {
      var o = this.selectedTargetOption()
      var a = action(o)
      var r = null
      do {
        r = window.prompt((r ? '"' + r + '"は登録済みです。\n' : '') + o.text
                        , r || '')
        if (r === null) return
        while (!a.valid(r)) {
          r = window.prompt(a.errMessage + o.text, r)
          if (r === null) return
        }
      } while (!a.add(this.model, r))
      this.getListSelect().appendChild(opt(r, r).new(this.doc))
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.removeSelectedItems = function() {
      var opts = [].slice.call(this.getListSelect().selectedOptions)
      opts.forEach(function(o) { o.parentNode.removeChild(o) })
      var selected = this.selectedTargetOption()
      action(selected).remove(this.model
                            , opts.map(function(o) { return o.value }))
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.removeAll = function() {
      var o = this.selectedTargetOption()
      if (!window.confirm('すべての"' + o.text + '"を削除しますか?')) return
      action(o).removeAll(this.model)
      ;[].slice.call(this.getListSelect().options).forEach(function(o) {
        o.parentNode.removeChild(o)
      })
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.openSelectedItems = function() {
      var a = action(this.selectedTargetOption())
      ;[].forEach.call(this.getListSelect().selectedOptions, function(o) {
        GM_openInTab(a.url(o.value))
      })
    }
    ConfigDialog.prototype.updateList = function() {
      ;[].slice.call(this.getListSelect().options).forEach(function(o) {
        o.parentNode.removeChild(o)
      })
      var o = this.selectedTargetOption()
      action(o).get(this.model).forEach(function(v) {
        this.getListSelect().appendChild(opt(v, v).new(this.doc))
      }, this)
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.updateButtonDisabled = function() {
      this.getRemoveAllButton().disabled = !this.getListSelect().options.length
      var disabled = this.getListSelect().selectedIndex === -1
      this.getRemoveButton().disabled = disabled
      this.getOpenButton().disabled = disabled
    }
    ConfigDialog.prototype.updateNewWindowOpen = function() {
      this.model.openNewWindow().set(this.getNewWindowOpenCheckbox().checked)
    }
    ConfigDialog.prototype.updateSeamlessRankingNumber = function () {
      this.model.seamlessRankingNumber()
        .set(this.getSeamlessRankingNumberCheckbox().checked)
    }
    ConfigDialog.prototype.updateUseGetThumbInfo = function() {
      this.model.useGetThumbInfo()
        .set(this.getUseGetThumbInfoCheckbox().checked)
    }
    ConfigDialog.prototype.updateRequestingNext = function() {
      this.model.requestingNext().set(this.getRequestingNextCheckbox().checked)
    }
    ConfigDialog.prototype.updateMovieInfoTogglable = function() {
      this.model.movieInfoTogglable()
        .set(this.getMovieInfoTogglableCheckbox().checked)
    }
    ConfigDialog.prototype.updateDescriptionTogglable = function() {
      this.model.descriptionTogglable()
        .set(this.getDescriptionTogglableCheckbox().checked)
    }
    ConfigDialog.prototype.updatePopupVisible = function() {
      this.model.popupVisible().set(this.getPopupVisibleCheckbox().checked)
    }
    return ConfigDialog
  })()

  var Controller = (function() {

    var movieByTarget = function(fn) {
      return function(target) {
        var movieId = this._view.movieIdByComponent(target)
        fn.call(this, this._model.movieById(movieId), target)
      }
    }
    var method = function(b) {
      return b ? 'remove' : 'add'
    }

    var Controller = function(model, view) {
      this._model = model
      this._view = view
    }
    Controller.prototype._addVisitedId = function(target) {
      this._model.visitedMovieIds().add(this._view.movieIdByComponent(target))
    }
    Controller.prototype._changeNgIds = movieByTarget(function(movie) {
      this._model.ngMovieIds()[method(movie.isNgId())](movie.id())
    })
    Controller.prototype._promptUntilAdd = function(movie) {
      var r = null
      do {
        var msg = (r ? '"' + r + '"は登録済みです。\n' : '') + 'NGタイトルを入力'
        r = prompt(msg, r ? r : movie.title())
      } while (r && !this._model.ngTitles().add(r))
    }
    Controller.prototype._changeNgTitles = movieByTarget(function(movie) {
      if (movie.isNgTitle()) {
        this._model.ngTitles().remove(movie.matchedNgTitle())
      } else {
        this._promptUntilAdd(movie)
      }
    })
    Controller.prototype._changeVisistedIds = movieByTarget(function(movie) {
      this._model.visitedMovieIds()[method(movie.isVisited())](movie.id())
    })
    Controller.prototype._changeNgTags = movieByTarget(function(movie, target) {
      var tag = this._view.tagByTagNgButton(target)
      this._model.ngTags()[method(movie.hasNgTag(tag))](tag)
    })
    Controller.prototype._changeNgContributors = movieByTarget(function(movie) {
      var methodName = method(movie.contributor().isNg())
      var storeName = movie.contributor().isUser()
                      ? 'ngUserIds' : 'ngChannelIds'
      this._model[storeName]()[methodName](movie.contributor().id())
    })
    Controller.prototype.clickCallback = function(event) {
      var t = event.target
      if (this._view.isThumbAnchor(t) || this._view.isTitleAnchor(t)) {
        this._addVisitedId(t)
      } else if (this._view.isMovieNgButton(t)) {
        this._changeNgIds(t)
      } else if (this._view.isTitleNgButton(t)) {
        this._changeNgTitles(t)
      } else if (this._view.isVisitButton(t)) {
        this._changeVisistedIds(t)
      } else if (this._view.isTagNgButton(t)) {
        this._changeNgTags(t)
      } else if (this._view.isContributorNgButton(t)) {
        this._changeNgContributors(t)
      } else if (this._view.isMovieInfoToggle(t)) {
        this._view.toggleMovieInfoVisible(event)
      } else if (this._view.isDescriptionToggle(t)) {
        this._view.toggleDescriptionExpanded(event)
      }
    }
    Controller.prototype.mouseOverCallback = function(event) {
      if (!this._model.popupVisible().get()) return
      var targetId = this._view.movieIdByComponent(event.target)
      var popupId = this._view.movieIdByPopup()
      if (targetId === popupId) return
      if (targetId) {
        this._view.addPopupMenuTo(event.target)
      } else {
        this._view.removePopupMenu()
      }
    }
    return Controller
  })()

  var main = function() {
    var view = MatrixView.is(window.location)
               ? new MatrixView() : new GinzaView()
    GM_addStyle(view.getCssText())
    document.addEventListener('DOMContentLoaded', function() {
      var movies = view.getMovies()
      var stores = newStores()
      var model = new Model(movies, view, stores)
      view.setModel(model)
      view.addControllers()
      updateMovies(movies, stores)

      var body = view.contentBody()
      var ctrl = new Controller(model, view)
      body.addEventListener('click', ctrl.clickCallback.bind(ctrl))
      body.addEventListener('mouseover', ctrl.mouseOverCallback.bind(ctrl))
      window.addEventListener('scroll', view.requestLoadingLazyImages.bind(view))

      var getThumbInfo = null
      if (stores.useGetThumbInfo.get()) {
        getThumbInfo = new GetThumbInfo(GM_xmlhttpRequest
                                      , model.sortMoviesByVisible()
                                      , stores
                                      , 5)
        getThumbInfo.request()
      } else {
        movies.forEach(callMethod('setGetThumbInfoDone', []))
      }
      if (stores.requestingNext.get() && view.isHourlyAll()) {
        view.requestToNicoChart(getThumbInfo)
      }
    })
  }

  main()
})()