Greasy Fork is available in English.

Nico Nico Ranking NG

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

Ajankohdalta 14.9.2015. Katso uusin versio.

// ==UserScript==
// @name Nico Nico Ranking NG
// @namespace http://userscripts.org/users/121129
// @description ニコニコ動画のランキングにNG機能を追加
// @match http://www.nicovideo.jp/ranking*
// @version 14
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_addStyle
// @license MIT License
// @noframes
// ==/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')),
      openNewWindow: new Store(o('openNewWindow', true)),
      useGetThumbInfo: new Store(o('useGetThumbInfo', true)),
      movieInfoTogglable: new Store(o('movieInfoTogglable', true)),
      descriptionTogglable: new Store(o('descriptionTogglable', 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.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)
    }
    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
      this._concurrent = concurrent
      this._stores = stores
    }
    GetThumbInfo.prototype._onerror = function(remainder, movie, message) {
      this._requestMovie(remainder, remainder.shift())
      movie.setThumbInfo({error: message})
    }
    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(remainder, movie, res) {
      this._requestMovie(remainder, remainder.shift())
      if (res.status === 200) {
        var thumbInfo = parseResText(res.responseText)
        movie.setThumbInfo(thumbInfo)
        if (!thumbInfo.error) this._setNg(movie)
      } else {
        movie.setThumbInfo({error: res.statusText})
      }
    }
    GetThumbInfo.prototype._requestMovie = function(remainder, movie) {
      if (!movie) return
      this._request({
        method: 'GET',
        url: 'http://ext.nicovideo.jp/api/getthumbinfo/' + movie.id(),
        timeout: 5000,
        onload: this._onload.bind(this, remainder, movie),
        onerror: this._onerror.bind(this, remainder, movie, 'エラー'),
        ontimeout: this._onerror.bind(this, remainder, movie, 'タイムアウト'),
      })
    }
    GetThumbInfo.prototype.request = function() {
      var remainder = this._movies.slice(this._concurrent)
      this._movies.slice(0, this._concurrent)
        .forEach(this._requestMovie.bind(this, remainder))
    }
    return GetThumbInfo
  })()

  var Model = (function() {

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

    var Model = function(movies, view, stores) {
      this._movies = movies
      this._view = view
      this._ngMovieVisible = false
      this._useGetThumbInfo = stores.useGetThumbInfo
      this._visitedMovieViewMode =
        new ViewModeStore(
          new ObservableStore(stores.visitedMovieViewMode
                            , view.updateVisitedMovieViewMode.bind(view)))
      this._openNewWindow =
        new ObservableStore(stores.openNewWindow
                          , view.updateNewWindowOpen.bind(view))
      this._movieInfoTogglable =
        new ObservableStore(stores.movieInfoTogglable
                          , view.updateMovieInfoTogglable.bind(view))
      this._descriptionTogglable =
        new ObservableStore(stores.descriptionTogglable
                          , view.updateDescriptionTogglable.bind(view))
      var p = Movie.prototype
      this._visitedMovieIds =
        new ObservableStore(stores.visitedMovieIds
                          , movieUpdater(movies, p.setVisitedIfInclude))
      this._ngMovieIds =
        new ObservableStore(stores.ngMovieIds
                          , movieUpdater(movies, p.setNgIdIfInclude))
      this._ngTitles =
        new ObservableStore(stores.ngTitles
                          , movieUpdater(movies, p.setNgTitleIfInclude))
      this._ngTags =
        new ObservableStore(stores.ngTags
                          , movieUpdater(movies, p.setNgTagsIfMatch))
      this._ngUserIds =
        new ObservableStore(stores.ngUserIds
                          , movieUpdater(movies, p.setNgUserIfInclude))
      this._ngChannelIds =
        new ObservableStore(stores.ngChannelIds
                          , movieUpdater(movies, p.setNgChannelIfInclude))
    }
    Model.prototype.movies = getter('_movies')
    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.openNewWindow = getter('_openNewWindow')
    Model.prototype.useGetThumbInfo = getter('_useGetThumbInfo')
    Model.prototype.movieInfoTogglable = getter('_movieInfoTogglable')
    Model.prototype.descriptionTogglable = getter('_descriptionTogglable')
    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._isHidden = function(movie) {
      return !this._isVisible(movie)
          || (movie.isVisited()
              && this.visitedMovieViewMode().get().isHideMode())
    }
    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() {
      var self = this
      return 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'))
    }
    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.DoNothing.prototype.updateView = function(view) {
    view.doNothingRadio.checked = true
  }

  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.Hide.prototype.updateView = function(view) {
    view.hideRadio.checked = true
  }

  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.updateView = function(view) {
    view.reduceRadio.checked = true
  }
  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)
      }
    }
    MovieView.prototype.updateTitle = function(movie) {
      this.title.setTitle(movie.title())
    }
    return MovieView
  })()

  var LazyImageLoader = (function() {
    var INTERVAL = 125
    var LazyImageLoader = function(doc) {
      this._doc = doc
      this._imgs = null
      this._requested = false
      this._requestedInInterval = false
    }
    LazyImageLoader.prototype._lazyImages = function() {
      if (this._imgs) {
        return this._imgs.filter(function(img) {
          return img.classList.contains('jsLazyImage')
        })
      }
      return [].slice.call(this._doc.querySelectorAll('img.thumb.jsLazyImage'))
    }
    LazyImageLoader.prototype._lazyImagesInView = function() {
      var divided = this._lazyImages().reduce((function(o, img) {
        if (this._isInView(img)) o.inView.push(img)
        else o.notInView.push(img)
        return o
      }).bind(this), {inView: [], notInView: []})
      this._imgs = divided.notInView
      return divided.inView
    }
    LazyImageLoader.prototype._isDone = function() {
      return this._imgs && this._imgs.length === 0
    }
    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._isDone()) return
      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 radio = function(doc, value, clickCallback) {
      return elem('input')
        .attr({
          'type': 'radio',
          'name': 'visitedMovieViewMode',
          'value': value,
        })
        .on('click', clickCallback)
        .new(doc)
    }
    var movieIdInHRef = function(href) {
      var execResult = /^watch\/([^?]+)/.exec(href)
      return execResult ? execResult[1] : null
    }
    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 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 GinzaView = function(doc) {
      doc = doc || document
      this.doc = doc
      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)

      var radioClickListener = this._setVisitedMovieViewMode.bind(this)
      this.reduceRadio = radio(doc, 'reduce', radioClickListener)
      this.hideRadio = radio(doc, 'hide', radioClickListener)
      this.doNothingRadio = radio(doc, 'doNothing', radioClickListener)
    }
    GinzaView.prototype.setModel = function(model) {
      this.model = model
      this.updateVisitedMovieViewMode()
      this.updateNewWindowOpen()
      this.updateMovieInfoTogglable()
    }
    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() {
      return array(this.doc.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(root) {
      return new MovieView(this, root, this._getMovieRoot(root))
    }
    GinzaView.prototype.getMovies = function() {
      var result = [], i2v = this.idToMovieView
      this._getWatchAnchors().forEach(function(a) {
        var id = movieIdInHRef(a.getAttribute('href'))
        var movieView = i2v[id] = this._newMovieView(a)
        result.push(new Movie(id, a.textContent, movieView))
      }, this)
      return result
    }
    GinzaView.prototype._newVisitedMovieViewModeFragment = function() {
      return elem()
        .add([
          '閲覧済みの動画を',
          elem('label').add(this.reduceRadio, ' 縮小').new(this.doc),
          elem('label').add(this.hideRadio, ' 非表示').new(this.doc),
          elem('label').add(this.doNothingRadio, ' 通常表示').new(this.doc),
        ])
        .new(this.doc)
    }
    GinzaView.prototype._newControllers = function() {
      return elem('div')
        .add([
          this._newVisitedMovieViewModeFragment(),
          ' | ',
          elem('label').add(this.ngMovieVisibleCheckbox, ' NG動画を表示')
            .new(this.doc),
          ' | ',
          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.model.visitedMovieViewMode().get().updateView(this)
      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.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() {
      if (this.reduceRadio.checked)
        this.model.visitedMovieViewMode().set(new ViewMode.Reduce())
      else if (this.hideRadio.checked)
        this.model.visitedMovieViewMode().set(new ViewMode.Hide())
      else if (this.doNothingRadio.checked)
        this.model.visitedMovieViewMode().set(new ViewMode.DoNothing())
      else
        throw new Error()
    }
    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._getRankingNumCounterCssText = 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%;',
        '}',
        '.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;',
        '}',
        this._getRankingNumCounterCssText(),
      ].join('\n')
    }
    return GinzaView
  })()

  var MatrixView = (function (_super) {
    var MatrixView = function () {
      _super.apply(this, arguments)
      this._relocationCellsRequested = false
    }
    MatrixView.is = function (location) {
      return location.pathname.startsWith('/ranking/matrix')
    }
    MatrixView.prototype = Object.create(_super.prototype)
    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;',
        '}',
        'td[data-video-item] {',
        '  position: relative;',
        '}',
        '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;',
        '}',
        '.nrn-reduce .itemThumbBox,',
        '.nrn-reduce .itemThumbBox .itemThumb {',
        '  width: 50px;',
        '  height: 38px;',
        '  margin: 0;',
        '}',
        '.top20 .itemTitle a.nrn-ng-movie-title {',
        '  text-decoration: line-through;',
        '}',
        '.nrn-hidden {',
        '  visibility: hidden;',
        '}',
        '.nrn-hidden > .nrn-movie-info-container,',
        '.nrn-hidden > .nrn-movie-info-toggle {',
        '  display: none;',
        '}',
      ].join('\n')
    }
    MatrixView.prototype.requestLoadingLazyImages = noop
    MatrixView.prototype.updateMovieInfoTogglable = noop
    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)
    }
    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.setVisible = function(visible) {
      this.root.classList[visible ? 'remove' : 'add']('nrn-hidden')
    }
    MatrixMovieView.prototype.getMovieDataElem = function() {
      return this.root
    }
    MatrixMovieView.prototype._isMovieInfoTogglable = always(true)
    MatrixMovieView.prototype.updateDescription = noop
    return MatrixMovieView
  })(MovieView)

  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 buttonStyle = {
      display: 'block',
      width: '100%',
      'margin-bottom': '5px',
    }
    var setupDoc = function(doc) {
      elem.css(doc.body, { width: '20em', margin: '8px' })
      return doc
    }
    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 checkbox = function(name, storeName) {
      var checked = storeName ? this.model[storeName]().get()
                              : this.model['is' + name]()
      return elem('input')
        .attr({ type: 'checkbox', checked: checked })
        .on('change', this['update' + name].bind(this))
        .new(this.doc)
    }
    var label = function(control, text) {
      return elem('label').css('display', 'block').add(control, text)
    }

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

      this.target = this.newTarget()
      this.list = this.newList()
      this.addButton = this.newAddButton()
      this.removeButton = this.newRemoveButton()
      this.removeAllButton = this.newRemoveAllButton()
      this.openButton = this.newOpenButton()
      this.newWindowOpenCheckbox =
        checkbox.call(this, 'NewWindowOpen', 'openNewWindow')
      this.useGetThumbInfoCheckbox =
        checkbox.call(this, 'UseGetThumbInfo', 'useGetThumbInfo')
      this.movieInfoTogglableCheckbox =
        checkbox.call(this, 'MovieInfoTogglable', 'movieInfoTogglable')
      this.descriptionTogglableCheckbox =
        checkbox.call(this, 'DescriptionTogglable', 'descriptionTogglable')
      this.closeButton = this.newCloseButton()
      this.doc.body.appendChild(this.newRoot())
      this.updateButtonDisabled()
    }
    ConfigDialog.Z_INDEX = 10000
    ConfigDialog.show = function(model, callback) {
      var f = document.createElement('iframe')
      f.addEventListener('load', function() {
        var d = new ConfigDialog(model, f.contentDocument)
        d.setup(f)
        if (callback) callback(d)
      })
      document.body.appendChild(f)
    }
    ConfigDialog.prototype.newTarget = function() {
      return elem('select')
        .css('width', '100%')
        .add([
          opt('ng-movie-id', 'NG動画ID').new(this.doc),
          opt('ng-title', 'NGタイトル', true).new(this.doc),
          opt('ng-tag', 'NGタグ').new(this.doc),
          opt('ng-user-id', 'NGユーザーID').new(this.doc),
          opt('ng-channel-id', 'NGチャンネルID').new(this.doc),
          opt('visited-movie-id', '閲覧済み動画ID').new(this.doc),
        ])
        .on('change', this.updateList.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newList = function() {
      return elem('select')
        .attr({ size: 10, multiple: true })
        .css('width', '100%')
        .add(this.model.ngTitles().get().map(function(t) {
          return opt(t, t).new(this.doc)
        }, this))
        .on('change', this.updateButtonDisabled.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newAddButton = function() {
      return elem('button')
        .css(buttonStyle)
        .add('追加')
        .on('click', this.addPromptResult.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newRemoveButton = function() {
      return elem('button')
        .css(buttonStyle)
        .add('削除')
        .on('click', this.removeSelectedItems.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newRemoveAllButton = function() {
      return elem('button')
        .css(buttonStyle)
        .add('全削除')
        .on('click', this.removeAll.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newOpenButton = function() {
      return elem('button')
        .css(buttonStyle)
        .add('開く')
        .on('click', this.openSelectedItems.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newCloseButton = function() {
      return elem('button')
        .css({ display: 'block', margin: '10px auto 0px' })
        .add('閉じる')
        .on('click', this.remove.bind(this))
        .new(this.doc)
    }
    ConfigDialog.prototype.newRoot = function() {
      return elem()
        .add([
          elem('div').css('width', '15em').add(this.target).new(this.doc),
          elem('div')
            .css({ width: '15em', float: 'left' })
            .add(this.list)
            .new(this.doc),
          elem('div')
            .css({ width: '5em', float: 'left' })
            .add([
              this.addButton,
              this.removeButton,
              this.removeAllButton,
              this.openButton,
            ])
            .new(this.doc),
          label(this.newWindowOpenCheckbox, '動画を別窓で開く')
            .css({ clear: 'both', 'padding-top': '10px '})
            .new(this.doc),
          label(this.useGetThumbInfoCheckbox, '動画情報を取得する')
            .new(this.doc),
          elem('fieldset')
            .add([
              elem('legend').add('表示・非表示の切り替えボタン').new(this.doc),
              label(this.movieInfoTogglableCheckbox, 'タグ、ユーザー、チャンネル')
                .new(this.doc),
              label(this.descriptionTogglableCheckbox, '動画説明')
                .new(this.doc),
            ])
            .new(this.doc),
          this.closeButton,
        ])
        .new(this.doc)
    }
    ConfigDialog.prototype.setup = function(iframe) {
      document.body.appendChild(this.background)

      this.iframe = elem.css(iframe, {
        'background-color': 'white',
        position: 'fixed',
        border: 'medium solid black',
        'z-index': ConfigDialog.Z_INDEX,
      })
      this.size(iframe)
      this.center(iframe)
    }
    ConfigDialog.prototype.size = function(iframe) {
      var s = this.doc.body.style
      iframe.width = this.doc.body.offsetWidth
                     + parseInt(s.marginLeft)
                     + parseInt(s.marginRight)
      iframe.height = this.doc.documentElement.offsetHeight
    }
    ConfigDialog.prototype.center = function(iframe) {
      iframe.style.left = ((window.innerWidth - iframe.width) / 2) + 'px'
      iframe.style.top = ((window.innerHeight - iframe.height) / 2) + 'px'
    }
    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.target.options[this.target.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.list.appendChild(opt(r, r).new(this.doc))
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.removeSelectedItems = function() {
      var opts = [].slice.call(this.list.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.list.options).forEach(function(o) {
        o.parentNode.removeChild(o)
      })
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.openSelectedItems = function() {
      var a = action(this.selectedTargetOption())
      ;[].forEach.call(this.list.selectedOptions, function(o) {
        GM_openInTab(a.url(o.value))
      })
    }
    ConfigDialog.prototype.updateList = function() {
      ;[].slice.call(this.list.options).forEach(function(o) {
        o.parentNode.removeChild(o)
      })
      var o = this.selectedTargetOption()
      action(o).get(this.model).forEach(function(v) {
        this.list.appendChild(opt(v, v).new(this.doc))
      }, this)
      this.updateButtonDisabled()
    }
    ConfigDialog.prototype.updateButtonDisabled = function() {
      this.removeAllButton.disabled = !this.list.options.length
      var disabled = this.list.selectedIndex === -1
      this.removeButton.disabled = disabled
      this.openButton.disabled = disabled
    }
    ConfigDialog.prototype.updateNewWindowOpen = function() {
      this.model.openNewWindow().set(this.newWindowOpenCheckbox.checked)
    }
    ConfigDialog.prototype.updateUseGetThumbInfo = function() {
      this.model.useGetThumbInfo().set(this.useGetThumbInfoCheckbox.checked)
    }
    ConfigDialog.prototype.updateMovieInfoTogglable = function() {
      this.model.movieInfoTogglable()
        .set(this.movieInfoTogglableCheckbox.checked)
    }
    ConfigDialog.prototype.updateDescriptionTogglable = function() {
      this.model.descriptionTogglable()
        .set(this.descriptionTogglableCheckbox.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) {
      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())
    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))

    if (stores.useGetThumbInfo.get()) {
      new GetThumbInfo(GM_xmlhttpRequest
                     , model.sortMoviesByVisible()
                     , stores
                     , 5).request()
    }
  }

  main()
})()