AbemaTV Screen Comment Scroller

AbemaTV のコメントをニコニコ風にスクロールさせます。

As of 2018-01-16. See the latest version.

// ==UserScript==
// @name        AbemaTV Screen Comment Scroller
// @namespace   knoa.jp
// @description AbemaTV のコメントをニコニコ風にスクロールさせます。
// @include     https://abema.tv/*
// @version     2.7.1
// @grant       none
// ==/UserScript==

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ScreenCommentScroller';
  const DEBUG = false;/*
  [update]
  本日のアベマの仕様変更に対応しました。

  [to do]
  番組開始直後のコメントにセレクタが付かないので一瞬レイアウトが崩れるううう。

  累計500バグが解消されてるかどうか随時確認。

  timeを活用して自然に流せるか確認。

  通知を受け取るボタン
    z-indexどうにもならんのかな…
      最悪左に移しちゃえばいいのかな>>ナビゲーション要素は右側に統一されてる
    番組開始後も取り残されることがある?
    > 番宣CM中に通知を受け取るをクリックしても次に同じCMがあったときにまたボタンが出てくるのって前からだった? 

  パフォーマンス
    結局テキストトランジションなら軽い可能性?
      テキストならscaleが効く
      -webkit-text-strokeが使えるっぽい
      そこまでやるならiframeも復活だろうか>>せめてbody直下がよいみたい
    コメントペインの背景用divを挿入してopacity制御にすれば…?
      同じレイヤーにしないと非効率。

  番組表と通知
    完成したら裏番組一覧のスクリーンショットはコメント一覧を重ねない&秒前なし&やや切り詰め&縁取り太め設定のものに差し替えか。
    ついでに設定パネルのも差し替え。NGのも公式ブロック表示させつつ。
    アドオン拡張化
      Qiitaで解説

  [possible]
  4:3の時にずらせる?
  greasemonkey 4 系対応
  ユーザーブロックアイコンのアニメーション
  「お試しNGワードのハイライト機能は、注目したいキーワードを含むコメントにも活用できます。」

  [requests]
  設定のナビゲーションに「マウスを近づけたら表示する」
  設定のスクロールコメントに「画面下部の専用領域に流す」「高さ(%)」
  設定の一覧コメントに「コメントをひとつずつ表示する」

  [not to do]
  新着コメント緑ボタン後の表示は現状簡単にはアニメーションさせられない
  設定のスクロールコメントに「フォント」<<Canvasベースラインの問題があるので厳しそう
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const CONFIGS = {
    /* スクロールコメント */
    maxlines:        {TYPE: 'int',   DEFAULT: 10  },/*最大行数(文字サイズ連動)*/
    linemargin:      {TYPE: 'float', DEFAULT: 0.20},/*行間(比率)*/
    transparency:    {TYPE: 'float', DEFAULT: 50.0},/*透明度(%)*/
    owidth:          {TYPE: 'float', DEFAULT: 0.05},/*縁取りの太さ(比率)*/
    duration:        {TYPE: 'float', DEFAULT: 5.00},/*横断にかける秒数*/
    maxcomments:     {TYPE: 'int',   DEFAULT: 100 },/*最大同時表示数*/
    /* 一覧コメント */
    l_hide:          {TYPE: 'bool',  DEFAULT: 1   },/*操作していない時は画面外に隠す*/
    l_overlay:       {TYPE: 'bool',  DEFAULT: 1   },/*映像に重ねる*/
    l_showtime:      {TYPE: 'bool',  DEFAULT: 1   },/*投稿時刻を表示する*/
    l_width:         {TYPE: 'float', DEFAULT: 16.5},/*横幅(%)*/
    lc_maxlines:     {TYPE: 'int',   DEFAULT: 30  },/*最大行数(文字サイズ連動)*/
    lc_linemargin:   {TYPE: 'float', DEFAULT: 0.50},/*改行されたコメントの行間(比率)*/
    lc_margin:       {TYPE: 'float', DEFAULT: 1.65},/*コメント同士の間隔(比率)*/
    lc_transparency: {TYPE: 'float', DEFAULT: 25.0},/*文字の透明度(%)*/
    lb_transparency: {TYPE: 'float', DEFAULT: 75.0},/*背景の透明度(%)*/
    /* アベマのナビゲーション */
    n_clickonly:     {TYPE: 'bool',  DEFAULT: 0   },/*画面クリック時のみ表示する*/
    n_delay:         {TYPE: 'float', DEFAULT: 4.00},/*隠れるまでの時間(秒)*/
    n_transparency:  {TYPE: 'float', DEFAULT: 50.0},/*透明度(%)*/
  };
  const PANELS = ['configPanel', 'ngList', 'ngHelp'];/*パネルの表示順*/
  const AINTERVAL = 7;/*AbemaTVのコメント取得間隔(s)の仕様値*/
  const STATSUPDATE = 1000*60;/*視聴数とコメント数を更新する間隔(ms)*/
  const FONT = 'Arial, sans-serif';/*スクロールフォント*/
  const BASELINE = 85/100;/*フォントのbaseline比率*/
  const MARGIN = 2/10;/*フォントサイズを飛び出す xgÅ(永◆∬∫√ ̄ などの文字を確実に収めるための余裕(比率)*/
  /* サイト定義 */
  let retry = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/
  let site = {
    targets: [
      /* 構造 */
      function header(){let header = $('body > div > div > header'); return (header) ? site.use(header) : null;},
      function footer(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen.parentNode.parentNode) : null;},
      function board(){let board = $('div[aria-hidden] form + div > div'); return (board) ? site.use(board) : null;},
      function screen(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.parentNode) : null;},
      /* ペイン */
      function commentPane(){let form = $('form:not([role="search"])'); return (form) ? site.use(form.parentNode.parentNode) : null;},
      function channelPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling) : null;},
      function programPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling.nextElementSibling) : null;},
      /* ボタン */
      function channelButtons(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode) : null;},
      function channelButton(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button) : null;},
      function commentButton(){let svg = $('use[*|href^="/images/icons/comment.svg"]'); return (svg) ? site.use(svg.parentNode.parentNode) : null;},
      function programButton(){let button = $('button[aria-label^="フルスクリーン"] + div + div > div > div'); return (button) ? site.use(button) : null;},
      function fullscreenButton(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen) : null;},
      function VolumeController(){let mute = $('button[aria-label="音声オンオフ切り替え"]'); return (mute) ? site.use(mute.parentNode.parentNode) : null;},
      function closer(){let buttons = elements.screen.querySelectorAll(selectors.screen + ' > div > button'); buttons.forEach((b) => site.use(b, 'closer')); return (buttons) ? true : null;},
      /* 要素 */
      function caution(){let header = $('header'); return (header) ? site.use(header.nextElementSibling) : null;},
      function commentForm(){let form = $('form:not([role="search"])'); return (form) ? site.use(form) : null;},
      function notice(){let buttons = elements.screen.querySelectorAll(selectors.screen + ' > div > div:last-child > button'); buttons.forEach((b) => site.use(b.parentNode, 'notice')); return (buttons) ? true : null;},
      function audienceTop(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling) : null;},
      function audience(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling.firstElementChild) : null;},
      function loading(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode) : null;},
      function programName(){let name = $('button[aria-label^="フルスクリーン"] + div + div div > p > span > span:last-child'); return (name) ? site.use(name) : null;},
      /* セレクタ定義のみ */
      function newCommentsButton(){return site.use(null);},
      function newComments(){return site.use(null);},
      function comment(){return site.use(null);},
    ],
    addedNode: {
      newCommentsButton: function(node){let button = node.parentNode.querySelector(selectors.commentPane + ' > div > button'); return (button) ? site.use(node) : null;},
      newComments: function(node){let commentText = node.querySelector(selectors.board + ' > div:not([data-selector]) > div > div > div > p'); return (commentText) ? site.use(node) && Array.from(commentText.parentNode.parentNode.parentNode.children).map(site.addedNode.comment) : null;},
      newComment: function(node){let commentText = node.querySelector(selectors.newComments + ' > div > div > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : null;},
      comment: function(node){let commentText = node.querySelector('div:not([data-selector]) > div > p:first-child'); return (commentText) ? site.use(node) : null;},
      progressbar: function(node){let circle = node.querySelector('svg circle'); return (circle) ? site.use(circle.parentNode.parentNode) : null;},
    },
    removedNode: {
      newComments: function(node){return (node.dataset.selector === 'newComments') ? node : null;},
      comment: function(node){return (node.dataset.selector === 'comment') ? node : null;},
    },
    get: {
      comments: function(newComments){return newComments.firstElementChild.children;},
      commentText: function(comment){return comment.firstElementChild.firstElementChild.textContent;},
      commentTime: function(comment){return comment.querySelector('time').textContent;},
      commentBlock: function(comment){return comment.querySelector('button[title="ブロック"]');},
      commentBlockCancel: function(comment){return comment.querySelector('form button');},
      latestTimeStamp: function(newComments){return newComments.querySelector('time').dateTime;},
      timeStamp: function(comment){return comment.querySelector('time').dateTime;},
      view: function(audience){return audience.querySelector('[data-selector="audience"] > span');},
      comment: function(commentButton){return commentButton.querySelector('[data-selector="commentButton"] > span');},
      closer: function(){
        /* チャンネル切り替えごとに変わる */
        let buttons = document.querySelectorAll(selectors.closer);
        for(let i = 0; buttons[i]; i++){
          if(buttons[i].clientWidth) return buttons[i];
        }
      },
      statsApi: function(){
        /* アベマの仕様に依存しまくり */
        if(!window.dataLayer) return;
        const API = 'https://api.abema.io/v1/broadcast/slots/{id}/stats';
        for(let i = window.dataLayer.length - 1; window.dataLayer[i]; i--){
          if(window.dataLayer[i].slotId) return API.replace('{id}', window.dataLayer[i].slotId);
        }
      },
    },
    cmNow: function(){
      return (elements.programName && elements.programName.textContent === '');
    },
    use: function use(target, key = use.caller.name){
      if(target) target.dataset.selector = key;
      selectors[key] = `[data-selector="${key}"]`;
      elements[key] = target;
      return true;
    },
  };
  /* 処理本体 */
  let html, elements = {}, selectors = {}, ngwords = [], configs = {};
  let canvas, context, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/
  let core = {
    initialize: function(){
      html = document.documentElement;
      core.config.read();
      core.ng.initialize();
      core.listenUserActions();
      core.checkUrl();
    },
    checkUrl: function(){
      let previousUrl = '';
      setInterval(function(){
        if(location.href === previousUrl) return;/*URLが変わってない*/
        if(location.href.startsWith('https://abema.tv/now-on-air/')){/*テレビ視聴ページ*/
          if(previousUrl.startsWith('https://abema.tv/now-on-air/')){/*チャンネルを変えただけ*/
            html.classList.remove('comment');
            html.classList.remove('ng');
            elements.closer = site.get.closer();
          }else{/*テレビ視聴ページになった*/
            core.ready();
          }
        }else{/*テレビ視聴ページではない*/
          core.gone();
        }
        previousUrl = location.href;
      }, 1000);
    },
    ready: function(){
      /* 必要な要素が出揃うまで粘る */
      for(let i = 0, target; target = site.targets[i]; i++){
        if(target() === null){
          if(!retry) return log(`Not found: ${target.name}, I give up.`);
          log(`Not found: ${target.name}, retrying...`);
          return retry-- && setTimeout(core.ready, 1000);
        }
      }
      elements.closer = site.get.closer();
      log("I'm Ready.");
      /* すべての要素が出揃っていたので */
      core.createCanvas();
      core.listenComments();
      core.ng.createButton();
      core.config.createButton();
      core.panel.createPanels();
      core.addStyle();
      html.classList.add(SCRIPTNAME);
      /* コメントを開けるようになったら自動で開く */
      let url = null;
      let observer = observe(elements.commentButton, function(records){
        if(elements.commentPane.attributes['aria-hidden'].value === 'false') return;/*既に表示中*/
        if(getComputedStyle(elements.commentButton).cursor !== 'pointer') return;/*まだクリックできない*/
        if(url !== location.href){/*チャンネル切り替え後の初回*/
          elements.commentButton.click();
          url = location.href;
        }else if(html.classList.contains('comment')){/*コメントを開いた状態で番組開始を迎えたとき*/
          core.closeOpenCommentPane();
        }
      }, {attributes: true});
    },
    gone: function(){
      if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style);
      html.classList.remove(SCRIPTNAME);
    },
    closeOpenCommentPane: function(){
      /* コメントが閉じられたと認識されたら即開き直す準備 */
      let observer = observe(elements.commentPane, function(records){
        if(elements.commentPane.attributes['aria-hidden'].value === 'false') return;
        observer.disconnect();/*一度だけ*/
        elements.commentButton.click();
        elements.commentPane.classList.remove('keep');
        canvas.classList.remove('keep');
      }, {attributes: true});
      /* ユーザーには閉じたように見せない */
      canvas.classList.add('keep');
      elements.commentPane.classList.add('keep');
      elements.closer.click();
    },
    updateStats: function(){
      /* mはアベマの仕様に合わせて小文字。しかし小数第1位は0も表示する。 */
      let formatNumber = function(number){
        switch(true){
          case(number < 1e3): return (number);
          case(number < 1e6): return (number/1e3).toFixed(1) + 'k';
          default:            return (number/1e6).toFixed(1) + 'm';
        }
      };
      let api = site.get.statsApi();
      if(!api) return log('Failed: site.get.statsApi.');
      let xhr = new XMLHttpRequest();
      xhr.open('GET', api);
      xhr.responseType = 'json';
      xhr.onreadystatechange = function(){
        if(xhr.readyState !== 4 || xhr.status !== 200) return;
        if(!xhr.response.stats || !xhr.response.stats.view || !xhr.response.stats.comment) return log(`Not found: stats`);
        //log('xhr.response:', xhr.response);
        site.get.view(elements.audience).textContent         = formatNumber(xhr.response.stats.view);
        site.get.comment(elements.commentButton).textContent = formatNumber(xhr.response.stats.comment);
      };
      xhr.send();
    },
    listenUserActions: function(){
      let id, timer = function(e){
        clearTimeout(id), id = setTimeout(function(){
          if(['input', 'textarea'].includes(document.activeElement.loaclName)) return;/*入力中はアクティブのまま*/
          html.classList.remove('active');
        }, configs.n_delay * 1000);
      };
      window.addEventListener('keydown', function(e){
        if(['input', 'textarea'].includes(e.target.localName)) e.stopPropagation();
      }, true);
      window.addEventListener('mousemove', function(e){
        if(configs.n_clickonly) return;
        if(!html.classList.contains('active')) html.classList.add('active');
        timer();
      });
      window.addEventListener('click', function(e){/*アベマより先にwindowでキャプチャ*/
        switch(e.target){
          case(elements.channelButton):
            return html.classList.toggle('channel');
          case(elements.programButton):
            return html.classList.toggle('program');
          case(elements.commentButton):
            if(html.classList.contains('comment')){
              animate(function(){elements.closer.click()});/*すぐクリックすると競合してしまうのでanimate()*/
            }else{
              html.classList.add('comment');
              if(!configs.l_overlay) core.modify();
              /* デフォルトのボタン動作が実行される */
            }
            return;
          case(elements.newCommentsButton):
            if(e.isTrusted){/*実クリックのみで処理*/
              elements.newCommentsButton.style.height = '0';
              /* スクロールをなめらかにする */
              let scrollTop = elements.board.parentNode.scrollTop;
              if(scrollTop){
                elements.board.style.transition = '500ms ease';
                elements.board.style.transform = `translateY(${scrollTop}px)`;
                elements.board.addEventListener('transitionend', function(e){
                  elements.board.style.transition = 'none';
                  elements.board.style.transform = 'translateY(0)';
                  elements.newCommentsButton.click();
                }, {once: true});
              }else{
                elements.newCommentsButton.click();
              }
              e.stopPropagation();
            }else{
              /* デフォルトのボタン動作が実行される */
            }
            return;
          case(elements.closer):
            if(html.classList.contains('comment')) core.ng.closeForm();/*NGフォームを開いているなら閉じる*/
            if(elements.commentPane.classList.contains('keep')) return html.classList.remove('comment');/*core.closeOpenCommentPaneですぐまた開かれる*/
            switch(true){
              case(html.classList.contains('channel')):
                html.classList.remove('channel');
                return e.stopPropagation();
              case(html.classList.contains('program')):
                html.classList.remove('program');
                return e.stopPropagation();
              default:
                if(e.isTrusted){/*実クリックではコメントは閉じない*/
                  e.stopPropagation();
                  html.classList.add('click');/*250msのtransition遅延をなくしてからキビキビactivate*/
                  animate(function(){
                    html.classList.toggle('active');
                    elements.header.addEventListener('transitionend', function(e){
                      html.classList.remove('click');
                    }, {once: true});
                  });
                  timer();
                }else{/*elements.closer.click()でのみ閉じる*/
                  html.classList.remove('comment');
                  if(!configs.l_overlay) core.modify();
                  /* default and propagateする */
                }
                return;
            }
          default:
            return;/*デフォルトの動作に任せる*/
        }
      }, true);
      /* アベマ公式ブロックを「コメントクリックでトグル」に差し替える */
      window.addEventListener('click', function(e){
        if(!e.isTrusted) return;
        let comment;
        for(let target = e.target; target; target = target.parentNode){
          if(target.localName === 'form') return;/*アベマ公式ブロックフォーム*/
          if(target === elements.board) return;
          if(target === document.body) return;
          if(target.dataset.selector === 'comment'){
            comment = target;
            break;
          }
        }
        if(!comment) return;
        let cancel = site.get.commentBlockCancel(comment);
        if(!cancel){
          /* ブロックフォームを開く */
          site.get.commentBlock(comment).click();
          comment.style.transition = 'background 500ms ease';
          comment.classList.add('blockform');
          let observer = observe(comment, function(records){
            if(site.get.commentBlockCancel(comment)) return;
            comment.classList.remove('blockform');
            observer.disconnect();
          });
        }else{
          /* ブロックフォームを閉じる */
          comment.classList.remove('blockform');
          comment.addEventListener('transitionend', function(e){
            comment.style.transition = 'none';
            cancel.click();
          });
        }
      }, true);
      /* 番組開始のタイミングを挟んだバックグラウンドからの復帰でコメント取得が停止する現象を防ぐ */
      document.addEventListener('visibilitychange', function(e){
        if(document.hidden) return;
        if(site.cmNow()) return;/*CM中はクリックしない*/
        if(html.classList.contains('comment')){
          core.closeOpenCommentPane();
        }
      });
      /* ウィンドウリサイズ */
      window.addEventListener('resize', function(e){
        if(!window.resizing) core.modify();
        clearTimeout(window.resizing), window.resizing = setTimeout(function(){
          core.modify();
          window.resizing = null;
        }, 500);
      });
      /* コメントペインを隠す設定でもコメント入力中は表示させる */
      if(configs.l_hide){
        window.addEventListener('focusin', function(e){
          if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.classList.add('active');
        });
        window.addEventListener('focusout', function(e){
          if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.classList.remove('active');
        });
      }
      /* コメントペインの開閉でcanvasサイズを再計算 */
      observe(html, function(records){
        if(!configs.l_overlay) core.modify();
      }, {attributes: true});
    },
    createCanvas: function(){
      if(canvas && canvas.isConnected) elements.screen.removeChild(canvas);
      /* コメントcanvasたちを格納する親 */
      canvas = createElement(core.html.canvasDiv());
      /* テキストサイズ計測に使用 */
      elements.preCanvas = createElement(core.html.preCanvas());
      context = elements.preCanvas.getContext('2d', {alpha: false});
      elements.screen.insertBefore(canvas, elements.screen.firstElementChild);
      core.modify();
    },
    modify: function(){
      /* スクリーンサイズを適切に変化させる */
      if(!elements.screen) return;/*フルスクリーン遷移時に対応*/
      let fullsize = (configs.l_overlay || !html.classList.contains('comment') || (configs.l_hide && html.classList.contains('comment') && !html.classList.contains('active')));
      let width = (fullsize) ? window.innerWidth : Math.round(window.innerWidth * (1 - configs.l_width / 100));
      let height = window.innerHeight;
      elements.screen.style.width = canvas.style.width = width + 'px';
      elements.screen.style.height = canvas.style.height = height + 'px';
      canvas.width = width;
      canvas.height = height;
      canvas.fontsize = Math.round((canvas.height / configs.maxlines) / (1 + configs.linemargin));
      context.font = `bold ${canvas.fontsize}px ${FONT}`;
      context.textBaseline = 'alphabetic';
      context.fillStyle = 'white';
      context.ngFillStyle = 'rgb(255,224,32)';/*独自指定*/
      context.strokeStyle = 'black';
      context.lineWidth = Math.round(canvas.fontsize * configs.owidth);
      context.lineJoin = 'round';
      canvas.topDelta = ((canvas.fontsize * configs.linemargin) - context.lineWidth - (canvas.fontsize * MARGIN)) / 2;/*canvasのtop計算に使用する*/
    },
    listenComments: function(){
      if(elements.commentPane.isListening) return;
      elements.commentPane.isListening = true;
      observe(elements.commentPane.firstElementChild, function(records){
        /* 新着コメント表示ボタン */
        if(records[0].addedNodes.length === 1 && site.addedNode.newCommentsButton(records[0].addedNodes[0]) !== null){
          let newCommentsButton = records[0].addedNodes[0];
          if(elements.board.classList.contains('mousedown')){/*テキスト選択を邪魔しないための配慮*/
            window.addEventListener('mouseup', function(){
              animate(function(){newCommentsButton.classList.add('shown')});
            }, {once: true});
          }else{
            animate(function(){newCommentsButton.classList.add('shown')});
          }
        }
      });
      observe(elements.board, function(records){
/* 取得したコメントが累計500を超えるとスクロールしなくなるアベマのバグに応急的に対応(1/2) */
if(elements.board.stalled){
  if(elements.board.children.length === 1) return;/*コメントを閉じた*/
  if(document.hidden) return;/*visibilitychangeに任せる*/
  if(!site.cmNow()){
    elements.board.stalled = false;
    return core.closeOpenCommentPane();
  }
  let oldest = Date.now(), getTime = (node) => parseInt(node.querySelector('time').dateTime);
  for(let i = records.length - 1; records[i]; i--){
    if(!records[i].addedNodes.length || site.addedNode.comment(records[i].addedNodes[0]) === null) continue;
    oldest = getTime(records[i].addedNodes[0]);
    break;
  }
  for(let i = 0; records[i]; i++){
    if(!records[i].addedNodes.length || site.addedNode.comment(records[i].addedNodes[0]) === null) continue;
    if(core.ng.filter(records[i].addedNodes[0])){
      setTimeout(function(){
        core.attachComment(site.get.commentText(records[i].addedNodes[0]));
      }, getTime(records[i].addedNodes[0]) - oldest);
    }
  }
  return;
}
        let replacedComments = [], ngFormIndex = null;
        for(let i = 0, record; record = records[i]; i++){
          switch(true){
            /* 新着コメント集 */
            case(record.addedNodes.length === 1 && site.addedNode.newComments(record.addedNodes[0]) !== null):
              core.receiveNewComments(elements.newComments);
              observe(elements.newComments.firstElementChild, function(records){
                for(let j = 0, record; record = records[j]; j++){
                  switch(true){
                    /* 新着単一コメント */
                    case (record.addedNodes.length === 1 && site.addedNode.newComment(record.addedNodes[0]) !== null):
                      /* セレクタを付与しただけで満足 */
                      break;
                    default:
if(records.length < 50 && record.addedNodes[0]) log(record.addedNodes[0]);
                      break;
                  }
                }
                core.receiveNewComments(elements.newComments);
              });
              break;
            /* 差し替え単一コメント(newComments内のcommentたちがごっそり新しいNodeに差し替えられてしまうアベマの悲しい仕様) */
            case(record.addedNodes.length === 1 && site.addedNode.comment(record.addedNodes[0]) !== null):
              core.ng.filter(record.addedNodes[0]);/*NGフィルタの再適用*/
              replacedComments.push(record.addedNodes[0]);
              /* 開いていたアベマ公式ブロックフォームを再現する */
              if(site.get.commentBlockCancel(record.addedNodes[0])){
                animate(function(){
                  record.addedNodes[0].classList.add('blockform');
                });
                let observer = observe(record.addedNodes[0], function(records){
                  if(site.get.commentBlockCancel(record.addedNodes[0])) return;
                  record.addedNodes[0].classList.remove('blockform');
                  observer.disconnect();
                });
              }
              break;
            /* 差し替えられたNodeの状態を再現する */
            case(record.removedNodes.length === 1 && site.removedNode.newComments(record.removedNodes[0]) !== null):
              /* 開いていたNG登録フォーム */
              if(elements.ngForm && elements.ngForm.parentNode.parentNode.parentNode === record.removedNodes[0]){
                ngFormIndex = Array.from(site.get.comments(record.removedNodes[0])).indexOf(elements.ngForm.parentNode);
              }
              /* 選択していたテキスト(対応しない) */
              break;
            default:
if(elements.board.children.length < 50 && records.length < 50 && record.addedNodes[0]) log(record.addedNodes[0]);
              break;
          }
        }
        if(ngFormIndex !== null) replacedComments[ngFormIndex].appendChild(elements.ngForm);
      });
    },
    receiveNewComments: function(newComments){
      let getDelay = function(text){
        switch(true){
          case(text === '今'): return 0;
          case(text.endsWith('秒前')): return parseInt(text);
          case(text.endsWith('分前')): return parseInt(text) * 60;
          case(text.endsWith('時間前')): return parseInt(text) * 60 * 60;
          default/*日前*/: return 60 * 60 * 24;
        }
      };
/* 取得したコメントが累計500を超えるとスクロールしなくなるアベマのバグに応急的に対応(2/2) */
const MAXLENGTH = 500, DURATION = 500, BUFFER = (configs.l_hide) ? 100 : 0;
let commentLength = site.get.comments(newComments).length + elements.board.children.length;
if(MAXLENGTH - BUFFER - 100 <= commentLength) log(elements.board.children.length, site.get.comments(newComments).length);
if(MAXLENGTH - BUFFER <= commentLength){
  if(site.cmNow()) elements.board.stalled = true;/*(CM中だとcloseOpenCommentPaneが効かないのでlistenCommentsに託す)*/
  else setTimeout(core.closeOpenCommentPane, DURATION);
}
      /* コメントの取得間隔を計測する(AINTERVAL仕様の変更に備える) */
      let now = Date.now(), commentInterval = (now - parseInt(newComments.dataset.received)) / 1000 || AINTERVAL;
      newComments.dataset.received = now;/*datasetを使うことでnewCommentsがなくなるときはいっしょになくなる*/
      /* コメント表示中に停止してしまう視聴数とコメント数をこのタイミングで更新する */
      if(!elements.commentButton.statsUpdated && !site.cmNow()/*CM中は更新しない*/){
        elements.commentButton.statsUpdated = true;
        core.updateStats();
        setTimeout(function(){elements.commentButton.statsUpdated = false}, STATSUPDATE);
      }
      /* NGコメントをすぐ判定する */
      core.ng.expire();
      let filteredComments = Array.from(site.get.comments(newComments)).filter(core.ng.filter);
      /* コメントの再取得で重複コメントが流れるのを回避する(NG判定をすませた後で) */
      let latest = parseInt(elements.board.dataset.latest);
      if(latest) filteredComments = filteredComments.filter(function(comment){
        return latest < parseInt(site.get.timeStamp(comment));
      });
      elements.board.dataset.latest = site.get.latestTimeStamp(newComments);
      /* バックグラウンドならここで終了 */
      if(document.hidden) return;
      /* スライドダウンアニメーションを上書きする */
      core.slideDownNewComments(newComments);
      /* 投稿経過時間に合わせた自然なばらつきでコメントを流すためのスケジュールを作る */
      let schedule = [];/*タイミングだけを格納する配列*/
      for(let i = 0; filteredComments[i]; i++){
        schedule.push(getDelay(site.get.commentTime(filteredComments[i])));
      }
      let lastIndex = schedule.length - 1, scale = (commentInterval) / (schedule[lastIndex] - schedule[0] + 1);
      schedule = schedule.map(/*最古のコメントを0として何秒後に流すべきかの配列を作る*/
        (delay, i, s) => s[lastIndex] - delay
      ).map(/*randomを加えて散らす*/
        (delay, i, s) => delay + (Math.random() * ((lastIndex - i) / lastIndex))
      ).sort(/*randomで乱れたぶんをソート*/
        (a, b) => b - a
      ).map(/*次のAINTERVALまでばらつきを平準化する*/
        (delay, i, s) => delay * scale
      );
      /* スケジュールに沿って配列末尾の古いコメントから順に流す */
      for(let i = filteredComments.length - 1, comment; comment = filteredComments[i]; i--){
        setTimeout(function(){
          core.attachComment(site.get.commentText(comment), comment.classList.contains('ng-trial'));
        }, 1000 * schedule[i]);
      }
    },
    slideDownNewComments: function(newComments){
      const duration = '500ms', easing = 'cubic-bezier(.215,.61,.355,1)';/*アベマ公式の挙動を尊重する*/
      newComments.style.maxHeight = newComments.style.minHeight = '0px';/*heightの上書き戦争を避けてmaxHeight/minHeightが使えるのは幸運*/
      animate(function(){
        let naturalHeight = getComputedStyle(newComments.firstElementChild).height;
        newComments.style.transition = 'none';
        newComments.firstElementChild.style.transition = 'none';
        newComments.firstElementChild.style.transform = `translateY(-${naturalHeight})`;
        animate(function(){
          newComments.style.transition = `max-height ${duration} ${easing}, min-height ${duration} ${easing}`;
          newComments.firstElementChild.style.transition = `transform ${duration} ${easing}`;
          animate(function(){
            newComments.style.maxHeight = newComments.style.minHeight = naturalHeight;
            newComments.firstElementChild.style.transform = `translateY(0)`;
          });
        });
      });
    },
    attachComment: function(text, ngTrial = false){
      if(canvas.children.length >= configs.maxcomments) return;
      /* 単一スクロールコメントcanvasを用意する */
      let scrollComment, c;
      let width = Math.round(context.measureText(text).width + context.lineWidth);
      let height = Math.round(canvas.fontsize * (1 + MARGIN) + context.lineWidth);
      scrollComment = createElement(core.html.scrollComment(width, height));
      c = scrollComment.getContext('2d');
      c.font         = context.font;
      c.textBaseline = context.textBaseline;
      c.fillStyle    = (ngTrial) ? context.ngFillStyle : context.fillStyle;
      c.strokeStyle  = context.strokeStyle;
      c.lineWidth    = context.lineWidth;
      c.lineJoin     = context.lineJoin;
      let left = Math.round(context.lineWidth/2);
      let top  = Math.round((canvas.fontsize * MARGIN + context.lineWidth)/2 + canvas.fontsize * BASELINE);
      c.strokeText(text, left, top);
      c.fillText(text, left, top);
      /* コメント位置データをまとめる */
      let record = {};
      record.text = text;/*流れる文字列*/
      record.width = width;/*文字列の幅*/
      record.ppms = (canvas.width + record.width) / (configs.duration * 1000);/*ミリ秒あたり移動距離*/
      record.start = Date.now();/*開始時刻*/
      record.reveal = record.start + (record.width / record.ppms);/*文字列が右端から抜ける時刻*/
      record.touch = record.start + (canvas.width / record.ppms);/*文字列が左端に触れる時刻*/
      record.end = record.start + (configs.duration * 1000);/*終了時刻*/
      /* 追加されたコメントをどの行に流すかを決定する */
      for(let i=0; i < configs.maxlines; i++){
        let length = lines[i] ? lines[i].length : 0;/*同じ行に詰め込まれているコメント数*/
        switch(true){
          /* 行がなければ行を追加して流す */
          case(length === 0):
            lines[i] = [];
          /* ひとつ先行するコメントより遅い(短い)文字列なら、現時点で先行コメントがすでに右端から抜けていれば流す */
          case(record.ppms < lines[i][length - 1].ppms && lines[i][length - 1].reveal < record.start):
          /* ひとつ先行するコメントより速い(長い)文字列なら、左端に触れる瞬間までに先行コメントが終了するなら流す */
          case(lines[i][length - 1].ppms < record.ppms && lines[i][length - 1].end < record.touch):
            record.top = Math.round(((canvas.height / configs.maxlines) * i) + canvas.topDelta);
            //if(DEBUG) scrollComment.dataset.former = JSON.stringify(lines[i][length - 1]);
            //if(DEBUG) scrollComment.dataset.self = JSON.stringify(record);
            lines[i].push(record);
            scrollComment.style.top = record.top + 'px';
            canvas.appendChild(scrollComment);
            animate(function(){
              scrollComment.style.transform = `translateX(-${canvas.width + width}px)`;
              scrollComment.addEventListener('transitionend', function(e){
                canvas.removeChild(scrollComment);
                lines[i].shift();
              }, {once: true});
            });
            return;/*行に追加したら終了*/
          default:
            continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/
        }
      }
    },
    ng: {
      initialize: function(){
        core.ng.read();
        core.ng.listenSelection();
      },
      listenSelection: function(){
        /* コメント上でmousedownした状態からのmousemove,mouseupでのみselect() */
        let select = function(e){
          let selection = window.getSelection(), selected = selection.toString(), comment = (selection.anchorNode) ? selection.anchorNode.parentNode.parentNode.parentNode.parentNode : null;
          /* テキスト選択なしなら登録フォームを閉じる */
          if(selection.isCollapsed && e.type === 'mouseup' && !e.target.dataset.ngword) return core.ng.closeForm();
          /* テキスト選択を邪魔しない場合にのみ登録フォームを表示 */
          if(!elements.ngForm || elements.ngForm.classList.contains('hidden') || e.target.offsetTop < elements.ngForm.offsetTop || e.type === 'mouseup') core.ng.openForm(comment, e);
          /* テキスト選択があれば初期値に */
          if(!selection.isCollapsed) elements.ngForm.querySelector('input[type="text"]').value = selected;
        };
        window.addEventListener('mousedown', function(e){
          for(let target = e.target; target.dataset; target = target.parentNode) if(target.dataset.selector === 'comment'){
            elements.board.classList.add('mousedown');
            window.addEventListener('mousemove', select);
            window.addEventListener('mouseup', function(e){
              animate(function(){select(e)});/*ダブルクリックでのテキスト選択をanimateで確実に補足*/
              window.removeEventListener('mousemove', select);
              elements.board.classList.remove('mousedown');
            }, {once: true});
            return;
          }
        });
      },
      createButton: function(){
        if(elements.ngButton) return;
        /* フルスクリーンボタンを元にNG一覧ボタンを追加する */
        elements.ngButton = createElement(core.html.ngButton());
        elements.ngButton.className = elements.fullscreenButton.className;
        elements.ngButton.addEventListener('click', core.panel.toggle.bind(null, 'ngList', core.ng.createList));
        elements.fullscreenButton.parentNode.insertBefore(elements.ngButton, elements.fullscreenButton);
      },
      createForm: function(comment){
        elements.ngForm = createElement(core.html.ngForm());
        elements.ngForm.querySelector('button.list').addEventListener('click', core.panel.toggle.bind(null, 'ngList', core.ng.createList));
        elements.ngForm.querySelector('button.help').addEventListener('click', core.panel.toggle.bind(null, 'ngHelp', core.ng.createHelp));
        elements.ngForm.querySelector('p.type').addEventListener('click', function(e){
          let word = elements.ngForm.querySelector('p.word input');
          if(word.value === '') return;
          if(e.target.localName !== 'button') return;
          core.ng.add(word, e.target);
          core.ng.closeForm();
          if(elements.ngList) core.ng.buildList();
        });
      },
      openForm: function(comment, e){
        let append = function(comment, ngForm){
          comment.insertBefore(ngForm, comment.firstElementChild.nextElementSibling);/*公式ブロックフォームが最後尾にある*/
        };
        let slideUpDown = function(){
          elements.ngForm.slidingUp = true;
          animate(function(){
            elements.ngForm.classList.add('hidden');
            if(elements.ngForm.isConnected){
              elements.ngForm.addEventListener('transitionend', function(e){
                elements.ngForm.slidingUp = false;
                append(elements.ngForm.targetComment, elements.ngForm);
                slideDown();
              }, {once: true});
            }else{
              elements.ngForm.slidingUp = false;
              append(elements.ngForm.targetComment, elements.ngForm);
              slideDown();
            }
          });
        };
        let slideDown = function(){
          elements.ngForm.slidingDown = true;
          if(elements.ngForm.parentNode !== elements.ngForm.targetComment) append(elements.ngForm.targetComment, elements.ngForm);
          animate(function(){
            elements.ngForm.classList.remove('hidden');
            elements.ngForm.addEventListener('transitionend', function(e){
              elements.ngForm.slidingDown = false;
            }, {once: true});
          });
          let ngword = elements.ngForm.targetComment.dataset.ngword;
          if(ngword && e.type === 'click') elements.ngForm.querySelector('input[type="text"]').value = ngword;
          if(!html.classList.contains('ng')) html.classList.add('ng');/*チャンネル切り替えナビゲーションを隠すなど*/
        };
        if(elements.board.parentNode.scrollTop === 0) elements.board.parentNode.scrollTop = 1;/*新着コメントを停止する*/
        if(elements.ngForm){/*表示位置の移し替え*/
          elements.ngForm.targetComment = comment;/*既にslideDown中の処理も含めてターゲットを差し替える*/
          if(elements.ngForm.classList.contains('hidden')){
            if(elements.ngForm.slidingUp){/*Up中*/
              if(elements.ngForm.parentNode === comment){
                slideDown();/*UpをやめてDownさせる*/
              }else{
                /*予定通りUp後にDownさせる*/
                elements.ngForm.addEventListener('transitionend', function(e){
                  slideDown();
                }, {once: true});
              }
            }else{/*hidden状態*/
              slideDown();
            }
          }else{
            if(elements.ngForm.slidingDown){/*Down中*/
              if(elements.ngForm.parentNode === comment){
                /*なにもしなくてもよい*/
              }else{
                slideUpDown();/*Downをやめて改めてUpDownさせる*/
              }
            }else{/*表示状態*/
              if(elements.ngForm.parentNode === comment){
                /*なにもしなくてもよい*/
              }else{
                slideUpDown();
              }
            }
          }
        }else{/*新規*/
          core.ng.createForm(comment);
          elements.ngForm.classList.add('hidden');
          elements.ngForm.targetComment = comment;
          slideDown();
        }
      },
      closeForm: function(){
        if(!elements.ngForm) return;
        if(elements.ngForm.classList.contains('hidden')) return;
        elements.ngForm.slidingUp = true;
        animate(function(){
          elements.ngForm.classList.add('hidden');
          if(elements.ngForm.isConnected){
            elements.ngForm.addEventListener('transitionend', function(e){
              elements.ngForm.slidingUp = false;
            }, {once: true});
          }else{
            elements.ngForm.slidingUp = false;
          }
        });
        html.classList.remove('ng');/*チャンネル切り替えナビゲーションを隠すなど*/
      },
      toggleForm: function(comment, e){
        if(!elements.ngForm) return core.ng.openForm(comment, e);
        if(elements.ngForm.classList.contains('hidden')) return core.ng.openForm(comment, e);
        if(elements.ngForm.parentNode !== comment) return core.ng.openForm(comment, e);
        core.ng.closeForm();
      },
      createList: function(){
        let ngList = elements.ngList = createElement(core.html.ngList());
        ngList.querySelector('button.help').addEventListener('click', core.panel.toggle.bind(null, 'ngHelp', core.ng.createHelp));
        ngList.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'ngList'));
        ngList.querySelector('button.save').addEventListener('click', function(e){
          core.ng.save(core.ng.getNewNgwords().filter((ngword) => (ngword.type !== 'remove')));
          core.panel.close('ngList');
        });
        ngList.querySelector('ul > li.add > p.words > textarea').addEventListener('keypress', function check(e){
          animate(function(){
            let checked = ngList.querySelector('ul > li.add > p.type input:checked');
            if(e.target.value === '') return checked && (checked.checked = false);
            if(!checked) ngList.querySelector('ul > li.add > p.type input[value="forever"]').checked = true;
          });
        });
        /* 並べ替え */
        configs.ng_sort = configs.ng_sort || {key: 'date', reverse: false};
        ngList.querySelector('p.sort').addEventListener('click', function(e){
          if(e.target.localName !== 'label') return;
          let input = document.getElementById(e.target.htmlFor);
          if(input.checked) input.classList.toggle('reverse');
          configs.ng_sort = {key: input.value, reverse: input.classList.contains('reverse')};
          core.ng.buildList();
        });
        /* リスト構築 */
        core.ng.buildList();
        /* 表示 */
        core.panel.open('ngList');
      },
      getNewNgwords: function(){
        let new_ngwords = Array.from(ngwords);/*clone*/
        /* input */
        let lis = elements.ngList.querySelectorAll('ul > li.edit');
        for(let i = 0, li; li = lis[i]; i++){
          let word = li.querySelector('p.word input');
          let checked = li.querySelector('p.type input:checked');
          let match = word.value.match(/^\/(.+)\/([a-z]+)?$/);
          new_ngwords[i] = {};
          new_ngwords[i].original = word.value;
          new_ngwords[i].value = (match) ? word.value : normalize(word.value).toLowerCase();
          new_ngwords[i].regex = (match) ? new RegExp(match[1], match[2]) : null;
          new_ngwords[i].type = checked.value;
          new_ngwords[i].added = parseInt(li.dataset.added) || null;
          new_ngwords[i].limit = (checked.value === 'for24h') ? parseInt(li.dataset.limit) : null;
        }
        /* textarea */
        let add = elements.ngList.querySelector('ul > li.add');
        let textarea = add.querySelector('p.words textarea');
        let lines = textarea.value.split('\n');
        for(let i = 0; lines[i] !== undefined; i++){
          let checked = add.querySelector('p.type input:checked');
          let match = lines[i].match(/^\/(.+)\/([a-z]+)?$/);
          let index = new_ngwords.length;
          new_ngwords[index] = {};
          new_ngwords[index].original = lines[i];
          new_ngwords[index].value = (match) ? lines[i] : normalize(lines[i]).toLowerCase();
          new_ngwords[index].regex = (match) ? new RegExp(match[1], match[2]) : null;
          new_ngwords[index].type = (checked) ? checked.value : null;
          new_ngwords[index].added = Date.now() + i;/*並べ替え用に同一時刻を避ける*/
          new_ngwords[index].limit = (checked && checked.value === 'for24h') ? new_ngwords[index].added + 1000*60*60*24 : null;
        }
        textarea.value = '';
        return new_ngwords.filter((ngword, index) => {
          if(ngword.value === '') return false;/*空欄除外*/
          for(let i = index + 1; new_ngwords[i]; i++) if(ngword.value === new_ngwords[i].value) return false;/*重複除外*/
          return true;
        });
      },
      buildList: function(){
        /* 編集中の既存のリストがあればそのまま使う */
        let new_ngwords = core.ng.getNewNgwords();
        /* 並べ替え */
        if(new_ngwords.length < 2){
          elements.ngList.querySelector('p.sort').classList.add('disabled');
        }else{
          elements.ngList.querySelector('p.sort').classList.remove('disabled');
          let sort = elements.ngList.querySelector(`p.sort input[value="${configs.ng_sort.key}"]`);
          sort.checked = true;
          if(configs.ng_sort.reverse) sort.classList.add('reverse');
        }
        new_ngwords.sort(function(a, b){
          let types = {trial: 1, for24h: 2, forever: 3, remove: 4};
          switch(configs.ng_sort.key){
            case('date'): return (a.added < b.added);
            case('word'): return (a.original < b.original);
            case('type'): return (a.limit && b.limit) ? (a.limit < b.limit) : (types[a.type] < types[b.type]);
          }
        });
        if(configs.ng_sort.reverse) new_ngwords.reverse();
        /* リスト構築 */
        let ul = elements.ngList.querySelector('ul');
        while(2 < ul.children.length) ul.removeChild(ul.children[1]);/*冒頭のテンプレートと追加登録のみ残す*/
        let template = ul.querySelector('li.template');
        let now = Date.now();
        let formatTime = function(limit){
          let left = limit - now;
          switch(true){
            case(1000*60*60 <= left): return Math.floor(left/(1000*60*60)) + '時間';
            case(0 <= left): return Math.floor(left/(1000*60)) + '分';
            case(left < 0): return '0分';
          }
        };
        for(let i = 0, new_ngword; new_ngword = new_ngwords[i]; i++){
          let li = template.cloneNode(true);
          li.className = 'edit';
          li.innerHTML = li.innerHTML.replace(/\{i\}/g, i);
          li.querySelector('p.word input').value = new_ngword.original || new_ngword.value/*移行用*/;
          if(new_ngword.type) li.querySelector(`p.type input[value="${new_ngword.type}"]`).checked = true;
          li.dataset.added = new_ngword.added || 0;
          li.dataset.limit = new_ngword.limit || 0;
          let for24h = li.querySelector('p.type label.for24h');
          for24h.textContent = (new_ngword.limit) ? formatTime(new_ngword.limit) : '24時間';
          for24h.addEventListener('click', function(e){
            animate(function(){/*checked処理の後に*/
              if(li.querySelector('p.type input[value="for24h"]').checked){
                if(for24h.classList.toggle('extended')){
                  li.dataset.limit = Date.now() + 1000*60*60*24;
                  for24h.textContent = '24時間';
                }else{
                  li.dataset.limit = new_ngword.limit;
                  for24h.textContent = formatTime(new_ngword.limit);
                }
              }
            });
          });
          ul.insertBefore(li, template.nextElementSibling);
        }
      },
      createHelp: function(){
        elements.ngHelp = createElement(core.html.ngHelp());
        elements.ngHelp.querySelector('button.ok').addEventListener('click', core.panel.close.bind(null, 'ngHelp'));
        core.panel.open('ngHelp');
      },
      add: function(word, type){
        let index = ngwords.length;
        for(let i = 0; ngwords[i]; i++) if(ngwords[i].value === word.value) index = i;/*重複させない*/
        let match = word.value.match(/^\/(.+)\/([a-z]+)?$/);
        if(!ngwords[index]) ngwords[index] = {};
        ngwords[index].original = word.value;
        ngwords[index].value = (match) ? word.value : normalize(word.value).toLowerCase();
        ngwords[index].regex = (match) ? new RegExp(match[1], match[2]) : null;
        ngwords[index].type = type.classList[0];
        ngwords[index].added = ngwords[index].added || Date.now();
        switch(true){
          case(type.classList.contains('for24h') && !ngwords[index].limit):
          case(type.classList.contains('for24h') && type.classList.contains('extended')):
            ngwords[index].limit = ngwords[index].added + 1000*60*60*24;
            break;
          case(type.classList.contains('for24h')):
            ngwords[index].limit = ngwords[index].limit;
            break;
          default:
            ngwords[index].limit = null;
            break;
        }
        Storage.save('ngwords', ngwords);
      },
      read: function(){
        /* 保存済みの設定を読む */
        ngwords = Storage.read('ngwords') || [];
        /* 正規表現(word.regex)はJSONに保存されないので復活させる */
        for(let i = 0; ngwords[i]; i++){
          let match = ngwords[i].value.match(/^\/(.+)\/([a-z]+)?$/);
          ngwords[i].regex = (match) ? new RegExp(match[1], match[2]) : null;
        }
      },
      save: function(new_ngwords){
        ngwords = new_ngwords;
        Storage.save('ngwords', ngwords);
      },
      expire: function(){
        let now = Date.now();
        ngwords = ngwords.filter(function(ngword, i, ngwords){
          if(!ngword.limit || now < ngword.limit) return true;
        });
      },
      filter: function(comment){
        const match = function(comment, ngword){
          let commentText = site.get.commentText(comment);
          if(ngword.regex && ngword.regex.test(commentText)) return true;
          if(normalize(commentText).toLowerCase().includes(ngword.value)) return true;
        };
        for(let i = 0, ngword; ngword = ngwords[i]; i++){
          switch(ngword.type){
            case('forever'):
            case('for24h'):
              if(match(comment, ngword)){
                comment.classList.add('ng-deleted');
                return false;
              }
              break;
            case('trial'):
              if(match(comment, ngword)){
                comment.classList.add('ng-trial');
                comment.dataset.ngword = ngword.value;
                comment.addEventListener('click', function(e){
                  if(e.target === comment && window.getSelection().isCollapsed) core.ng.toggleForm(comment, e);
                });
              }
              break;
          }
        }
        return true;
      },
    },
    config: {
      read: function(){
        /* 保存済みの設定を読む */
        configs = Storage.read('configs') || {};
        /* 未定義項目をデフォルト値で上書きしていく */
        Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT});
      },
      save: function(new_config){
        configs = {};/*CONFIGSに含まれた設定値のみ保存する*/
        /* CONFIGSを元に文字列を型評価して値を格納していく */
        Object.keys(CONFIGS).forEach((key) => {
          /* 値がなければデフォルト値 */
          if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT;
          switch(CONFIGS[key].TYPE){
            case 'bool':
              configs[key] = (new_config[key]) ? 1 : 0;
              break;
            case 'int':
              configs[key] = parseInt(new_config[key]);
              break;
            case 'float':
              configs[key] = parseFloat(new_config[key]);
              break;
            default:
              configs[key] = new_config[key];
              break;
          }
        });
        Storage.save('configs', configs);
      },
      createButton: function(){
        if(elements.configButton) return;
        /* フルスクリーンボタンを元に設定ボタンを追加する */
        elements.configButton = createElement(core.html.configButton());
        elements.configButton.className = elements.fullscreenButton.className;
        elements.configButton.addEventListener('click', core.panel.toggle.bind(null, 'configPanel', core.config.createPanel));
        elements.fullscreenButton.parentNode.insertBefore(elements.configButton, elements.ngButton);
      },
      createPanel: function(){
        elements.configPanel = createElement(core.html.configPanel());
        elements.configPanel.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'configPanel'));
        elements.configPanel.querySelector('button.save').addEventListener('click', function(e){
          let inputs = elements.configPanel.querySelectorAll('input'), new_configs = {};
          for(let i = 0, input; input = inputs[i]; i++){
            switch(CONFIGS[input.name].TYPE){
              case('bool'):
                new_configs[input.name] = (input.checked) ? 1 : 0;
                break;
              case('object'):
                if(!new_configs[input.name]) new_configs[input.name] = {};
                new_configs[input.name][input.value] = (input.checked) ? 1 : 0;
                break;
              default:
                new_configs[input.name] = input.value;
                break;
            }
          }
          core.config.save(new_configs);
          core.panel.close('configPanel')
          /* 新しい設定値で再スタイリング */
          core.addStyle();
          core.createCanvas();/*modify含む*/
        }, true);
        core.panel.open('configPanel');
      },
    },
    panel: {
      createPanels: function(){
        if(elements.panels) return;
        elements.panels = createElement(core.html.panels());
        elements.panels.dataset.panels = 0;
        document.body.appendChild(elements.panels);
      },
      open: function(key){
        let target = null;
        for(let i = PANELS.indexOf(key) + 1; PANELS[i] && !target; i++) if(elements[PANELS[i]]) target = elements[PANELS[i]];
        elements[key].classList.add('hidden');
        elements.panels.insertBefore(elements[key], target);
        animate(function(){
          elements.panels.dataset.panels = parseInt(elements.panels.children.length);
          elements[key].classList.remove('hidden');
        });
        elements.panels.listeningKeypress = elements.panels.listeningKeypress || [];
        if(!elements.panels.listeningKeypress[key]){
          elements.panels.listeningKeypress[key] = true;
          window.addEventListener('keypress', function(e){
            if(['input', 'textarea'].includes(document.activeElement.localName)) return;
            if(elements[key] && e.key === 'Escape') core.panel.close(key);
          });
        }
      },
      close: function(key){
        elements[key].classList.add('hidden');
        elements[key].addEventListener('transitionend', function(e){
          if(!elements[key]) return;
          elements.panels.dataset.panels = parseInt(elements.panels.children.length - 1);
          elements.panels.removeChild(elements[key]);
          elements[key] = null;
        }, {once: true});
      },
      toggle: function(key, create){
        (!elements[key]) ? create() : core.panel.close(key);
      },
    },
    addStyle: function(){
      let style = createElement(core.html.style());
      document.head.appendChild(style);
      if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style);
      elements.style = style;
    },
    html: {
      canvasDiv: () => `
        <div id="${SCRIPTNAME}-canvas"></div>
      `,
      preCanvas: () => `
        <canvas width="0" height="0"></canvas>
      `,
      scrollComment: (width, height) => `
        <canvas class="comment" width="${width}" height="${height}"></canvas>
      `,
      ngButton: () => `
        <button id="${SCRIPTNAME}-ng-button" title="${SCRIPTNAME} 登録NGワード一覧"><svg width="20" height="20"><use xlink:href="/images/icons/list.svg#svg-body"></use></svg></button>
      `,
      ngForm: () => `
        <div id="${SCRIPTNAME}-ng-form">
          <h1><span>NGワード登録</span><button class="list"><svg width="14" height="16"><use xlink:href="/images/icons/list.svg#svg-body"></use></svg></button></h1>
          <p class="word"><input type="text" value=""><button class="help">?</button></p>
          <p class="type"><button class="trial">お試し</button><button class="for24h">24時間</button><button class="forever">無期限</button></p>
        </div>
      `,
      ngList: () => `
        <div class="panel" id="${SCRIPTNAME}-ng-list">
          <header>
            <h1>登録NGワード一覧</h1>
            <p class="buttons"><button class="help">?</button></p>
          </header>
          <p class="sort">
            <input type="radio" name="sort" id="ngwords-sort-date" value="date"><label for="ngwords-sort-date">登録日時順</label>
            <input type="radio" name="sort" id="ngwords-sort-word" value="word"><label for="ngwords-sort-word">NGワード順</label>
            <input type="radio" name="sort" id="ngwords-sort-type" value="type"><label for="ngwords-sort-type">期限順</label>
          </p>
          <ul>
            <li class="template">
              <p class="word"><input type="text" name="ngwords[{i}][value]" value=""></p>
              <p class="type">
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-trial"   value="trial"  ><label class="trial"   for="ngwords-type-{i}-trial"  >お試し</label>
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-for24h"  value="for24h" ><label class="for24h"  for="ngwords-type-{i}-for24h" >24時間</label>
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-forever" value="forever"><label class="forever" for="ngwords-type-{i}-forever">無期限</label>
                <input type="radio" name="ngwords[type][{i}]" id="ngwords-type-{i}-remove"  value="remove" ><label class="remove"  for="ngwords-type-{i}-remove" >削除</label>
              </p>
            </li>
            <li class="add">
              <p class="words"><textarea name="ngwords[add][value]" placeholder="追加"></textarea></p>
              <p class="type">
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-trial"   value="trial"  ><label class="trial"   for="ngwords-type-add-trial"  >お試し</label>
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-for24h"  value="for24h" ><label class="for24h"  for="ngwords-type-add-for24h" >24時間</label>
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-forever" value="forever"><label class="forever" for="ngwords-type-add-forever">無期限</label>
                <input type="radio" name="ngwords[type][add]" id="ngwords-type-add-remove"  value="remove" ><label class="remove"  for="ngwords-type-add-remove" >削除</label>
              </p>
            </li>
          </ul>
          <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p>
        </div>
      `,
      ngHelp: () => `
        <div class="panel" id="${SCRIPTNAME}-ng-help">
          <h1>NGワードについて</h1>
          <p>登録したワードを含むコメントを削除します。</p>
          <p>お試しの場合はコメント一覧でハイライトされます。</p>
          <p>右下の一覧ボタンやコメントのテキスト選択から登録できます。</p>
          <p>英数字と記号とカタカナは全角半角や大文字小文字を区別しません。</p>
          <p>下記のような正規表現も使えます。</p>
          <section>
            <h2>「NGです」を消す登録例:</h2>
            <dl>
              <dt><code>NG</code></dt><dd>通常のNGワード</dd>
              <dt><code>/^NG/</code></dt><dd>前方一致</dd>
              <dt><code>/です$/</code></dt><dd>後方一致</dd>
              <dt><code>/^NGです$/</code></dt><dd>完全一致</dd>
            </dl>
            <h2>そのほかの例:</h2>
            <dl>
              <dt><code>/^.$/</code></dt><dd>1文字だけのコメント</dd>
              <dt><code>/.{30}/</code></dt><dd>30文字以上のコメント</dd>
              <dt><code>/^[a-z]+$/i</code></dt><dd>アルファベットだけのコメント</dd>
              <dt><code>/[0-9]{3}/</code></dt><dd>3桁以上の数字を含むコメント</dd>
            </dl>
          </section>
          <p class="buttons"><button class="ok primary">OK</button></p>
        </div>
      `,
      configButton: () => `
        <button id="${SCRIPTNAME}-config-button" title="${SCRIPTNAME} 設定"><svg width="20" height="20" role="img"><use xlink:href="/images/icons/config.svg#svg-body"></use></svg></button>
      `,
      configPanel: () => `
        <div class="panel" id="${SCRIPTNAME}-config-panel">
          <h1>${SCRIPTNAME}設定</h1>
          <fieldset>
            <legend>スクロールコメント</legend>
            <p><label>最大行数(文字サイズ連動):       <input type="number"   name="maxlines"        value="${configs.maxlines}"        min="1"  max="50"  step="1"></label></p>
            <p><label>行間(比率):                     <input type="number"   name="linemargin"      value="${configs.linemargin}"      min="0"  max="1"   step="0.05"></label></p>
            <p><label>透明度(%):                      <input type="number"   name="transparency"    value="${configs.transparency}"    min="0"  max="100" step="5"></label></p>
            <p><label>縁取りの太さ(比率):             <input type="number"   name="owidth"          value="${configs.owidth}"          min="0"  max="0.5" step="0.01"></label></p>
            <p><label>横断にかける秒数:               <input type="number"   name="duration"        value="${configs.duration}"        min="1"  max="30"  step="1"></label></p>
            <p><label>最大同時表示数:                 <input type="number"   name="maxcomments"     value="${configs.maxcomments}"     min="0"  max="100" step="1"></label></p>
          </fieldset>
          <fieldset>
            <legend>一覧コメント</legend>
            <p><label>操作していない時は画面外に隠す: <input type="checkbox" name="l_hide"          value="${configs.l_hide}"          ${configs.l_hide      ? 'checked' : ''}></label></p>
            <p><label>映像に重ねる:                   <input type="checkbox" name="l_overlay"       value="${configs.l_overlay}"       ${configs.l_overlay   ? 'checked' : ''}></label></p>
            <p><label>投稿時刻を表示する:             <input type="checkbox" name="l_showtime"      value="${configs.l_showtime}"      ${configs.l_showtime  ? 'checked' : ''}></label></p>
            <p><label>横幅(%):                        <input type="number"   name="l_width"         value="${configs.l_width}"         min="0"  max="100" step="0.5"></label></p>
            <p><label>最大行数(文字サイズ連動):       <input type="number"   name="lc_maxlines"     value="${configs.lc_maxlines}"     min="10" max="100" step="1"></label></p>
            <p><label>改行されたコメントの行間(比率): <input type="number"   name="lc_linemargin"   value="${configs.lc_linemargin}"   min="0"  max="1"   step="0.05"></label></p>
            <p><label>コメント同士の間隔(比率):       <input type="number"   name="lc_margin"       value="${configs.lc_margin}"       min="0"  max="2"   step="0.05"></label></p>
            <p><label>文字の透明度(%):                <input type="number"   name="lc_transparency" value="${configs.lc_transparency}" min="0"  max="100" step="5"></label></p>
            <p><label>背景の透明度(%):                <input type="number"   name="lb_transparency" value="${configs.lb_transparency}" min="0"  max="100" step="5"></label></p>
          </fieldset>
          <fieldset>
            <legend>アベマのナビゲーション</legend>
            <p><label>画面クリック時のみ表示する:     <input type="checkbox" name="n_clickonly"     value="${configs.n_clickonly}"     ${configs.n_clickonly ? 'checked' : ''}></label></p>
            <p><label>隠れるまでの時間(秒):           <input type="number"   name="n_delay"         value="${configs.n_delay}"         min="1"  max="60"  step="1"></label></p>
            <p><label>透明度(%):                      <input type="number"   name="n_transparency"  value="${configs.n_transparency}"  min="0"  max="100" step="5"></label></p>
          </fieldset>
          <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</button></p>
        </div>
      `,
      panels: () => `
        <div class="panels" id="${SCRIPTNAME}-panels"></div>
      `,
      style: () => `
        <style type="text/css">
          /* 共通変数 */
          /* opacity:               ${configs.opacity    = 1 - (configs.transparency / 100)} */
          /* lc_opacity:            ${configs.lc_opacity = 1 - (configs.lc_transparency / 100)} */
          /* lb_opacity:            ${configs.lb_opacity = 1 - (configs.lb_transparency / 100)} */
          /* n_opacity:             ${configs.n_opacity  = 1 - (configs.n_transparency / 100)} */
          /* opacityHover:          ${configs.opacityHover    = 1 - (configs.transparency / 200)} */
          /* lc_opacityHover:       ${configs.lc_opacityHover = 1 - (configs.lc_transparency / 200)} */
          /* lb_opacityHover:       ${configs.lb_opacityHover = 1 - (configs.lb_transparency / 200)} */
          /* n_opacityHover:        ${configs.n_opacityHover  = 1 - (configs.n_transparency / 200)} */
          /* fontsize:              ${configs.fontsize = (100 / configs.maxlines) / (1 + configs.linemargin)} (設定値の表現をわかりやすくする代償はここで支払う) */
          /* lc_fontsize:           ${configs.lc_fontsize = (100 / (configs.lc_maxlines + 1)) / (1 + configs.lc_margin)} (設定値の表現をわかりやすくする代償はここで支払う) */
          /* header_height:         ${configs.header_height = configs.header_height || elements.header.firstElementChild.clientHeight} */
          /* footer_height:         ${configs.footer_height = configs.footer_height || elements.footer.firstElementChild.clientHeight} */
          /* channelButtons_size:   ${configs.channelButtons_size = configs.channelButtons_size || elements.channelButtons.firstElementChild.clientWidth} */
          /* screen_zIndex:         ${configs.screen_zIndex         =   2} */
          /* canvas_zIndex:         ${configs.canvas_zIndex         =   3} */
          /* header_zIndex:         ${configs.header_zIndex         =   8} */
          /* commentPane_zIndex:    ${configs.commentPane_zIndex    =   9} */
          /* headerHover_zIndex:    ${configs.headerHover_zIndex    =  10} */
          /* footer_zIndex:         ${configs.footer_zIndex         =  10} */
          /* channelPane_zIndex:    ${configs.channelPane_zIndex    =  11} */
          /* programPane_zIndex:    ${configs.programPane_zIndex    =  11} */
          /* channelButtons_zIndex: ${configs.channelButtons_zIndex =  12} */
          /* panel_zIndex:          ${configs.panel_zIndex          = 100} */
          /* nav_transition:        ${configs.nav_transition        = '500ms cubic-bezier(.17,.84,.44,1)'} (Quartic) */
          /* nav_transitionDelay:   ${configs.nav_transitionDelay   = '500ms cubic-bezier(.17,.84,.44,1) 250ms'} (Quartic) */
          /* アベマの公式広告 */
          /* (2018/1に現れた謎要素がレイアウトを崩すので、とりあえず穏便に表示位置の調整で対応する) */
          .pub_300x250,
          .pub_300x250m,
          .pub_728x90,
          .text-ad,
          .textAd,
          .text_ad,
          .text_ads,
          .text-ads,
          .text-ad-links,
          dummy{
            bottom: 0;
          }
          /* closer */
          ${selectors.closer}{
            pointer-events: auto;
          }
          /* スクロールコメント */
          #${SCRIPTNAME}-canvas{
            z-index: ${configs.canvas_zIndex};
            pointer-events: none;
            position: absolute;
            top: 0;
            left: 0;
            overflow: hidden;
            opacity: 0;/*コメント非表示なら速やかに消える*/
            transition: opacity 500ms ease 250ms;
          }
          html.comment #${SCRIPTNAME}-canvas,
          #${SCRIPTNAME}-canvas.keep{
            opacity: ${configs.opacity};
          }
          #${SCRIPTNAME}-canvas > canvas{
            position: absolute;
            left: 100%;
            transition: transform ${configs.duration}s linear;
            will-change: transform;
            pointer-events: none;/*継承されないので*/
          }
          /* 映像 */
          ${selectors.screen}{
            transition: ${configs.nav_transition};
          }
          ${selectors.screen} > div:not(${selectors.audienceTop}):not(${selectors.loading}){
            width: 100% !important;
            height: 100% !important;
            transition: ${configs.nav_transition};
          }
          /* コメントペインの表示非表示 */
          ${selectors.commentPane}{
            width: auto;
            padding-left: ${configs.l_hide ? configs.l_width : configs.l_width / 4}vw;
            transform: translateX(100%);
            z-index: ${configs.commentPane_zIndex};
            transition: ${configs.nav_transitionDelay};
          }
          html.click ${selectors.commentPane}{
            transition: ${configs.nav_transition};
          }
          html.comment ${selectors.commentPane},
          ${selectors.commentPane}.keep/*core.closeOpenCommentPane用*/{
            transform: translateX(${configs.l_hide ? 50 : 0}%);
          }
          html.comment ${selectors.commentPane}:hover,
          html.comment ${selectors.commentPane}.active,
          html.comment.active ${selectors.commentPane}{
            transform: translateX(0);/*表示*/
            padding-left: ${configs.l_hide ? configs.l_width /4 : configs.l_width / 4}vw;/*隠れているときもマウスオーバー領域を確保する*/
          }
          ${selectors.commentPane} > div{
            width: ${configs.l_width}vw;
          }
          ${selectors.commentPane} [role="progressbar"]{
            margin: 0 40% 0 auto;/*どうにかこうにか真ん中へ*/
            transform: translateX(50%);
          }
          html:not(.comment) ${selectors.commentPane} [role="progressbar"] *{
            animation: none;/*画面外のローディングアニメーションにCPUを消費するアベマの悲しい仕様を上書き*/
          }
          /* コメントペインの透過 */
          ${selectors.commentPane} > div{
            background: rgba(0,0,0,${configs.l_overlay ? configs.lb_opacity : 1});
            -webkit-mask-image: linear-gradient(black 50%, transparent 100%);/*まだ-webkit取れない*/
            mask-image: linear-gradient(black 50%, transparent 100%);
            height: ${configs.l_overlay ? '100%' : '200%'};/*映像に重ねているときのみグラデーション効果の範囲内にする*/
            transition: 500ms ease;
          }
          ${selectors.commentPane}:hover > div{
            height: ${configs.l_overlay ? '200%' : '200%'};/*常に見やすく*/
            background: rgba(0,0,0,${configs.l_overlay ? configs.lb_opacityHover : 1});
          }
          ${selectors.footer}:hover ~ div > ${selectors.commentPane} > div,
          html.active ${selectors.commentPane} > div{
            height: 100%;/*gradientでtransitionが効かないのでheightで代用*/
            background: rgba(0,0,0,${configs.l_overlay ? configs.lb_opacity : .5});/*透明度を指定しないと効かない*/
          }
          ${selectors.commentPane},
          ${selectors.commentPane} *,
          ${selectors.commentPane} *:hover{
            color: rgba(255,255,255,${configs.l_overlay ? configs.lc_opacity : 1}) !important;
            background: transparent ;
          }
          ${selectors.commentPane}:hover *{
            color: rgba(255,255,255,${configs.l_overlay ? configs.lc_opacityHover : 1});
          }
          /* コメントペインの統一フォントサイズ */
          ${selectors.commentPane} *{
            font-size: ${configs.lc_fontsize}vh;
          }
          /* コメント投稿フォーム*/
          ${selectors.commentForm},
          ${selectors.commentForm} */*リセット*/{
            padding: 0;
            margin: 0;
          }
          ${selectors.commentForm}{
            width: auto;
            padding: 0 .75vw;
          }
          ${selectors.commentForm} > div:first-child/*textarea*/{
            background: rgba(32,32,32,${configs.l_overlay ? configs.lb_opacity : 1});
            border-radius: .2vw;
            padding: .5vw;
            margin: .75vw 0;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child/*(Twitter)連携する/連携中*/{
            width: 100%;
            padding-bottom: 1vw;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child > div/*(Twitter)連携する/連携中*/{
            width: calc(100% - 1vw);
            border-radius: .2vw;
            padding: 0 .5vw;
            height: ${configs.lc_fontsize * 2}vh;
            line-height: ${configs.lc_fontsize * 2}vh;
            overflow: hidden;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child > div[class*=" "]/*(Twitter)連携中*/{
            background: rgba(80,163,225,${configs.l_overlay ? configs.lb_opacityHover : 1});
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:first-child > div > svg/*(Twitter)アイコン*/{
            width: ${configs.lc_fontsize * (17/13)}vh;
            height: ${configs.lc_fontsize}vh;
            margin-right: 0.2vw;
          }
          ${selectors.commentForm} > div:last-child > div:last-child > span/*残り文字数*/{
            padding: .5vw;
          }
          ${selectors.commentForm} > div:last-child > div:last-child > button/*投稿する*/{
            border-radius: .2vw;
            padding: 0 .5vw;
            height: ${configs.lc_fontsize * 2}vh;
            line-height: ${configs.lc_fontsize * 2}vh;
            overflow: hidden;
            background: rgba(81,195,0,${configs.l_overlay ? configs.lb_opacityHover : 1});
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2)/*(Twitterアカウントパネル)*/{
            border-radius: .2vw;
            background: rgba(0,0,0,${configs.lb_opacityHover});
            width: calc(100% - 1.5vw);
            bottom: -${configs.lc_fontsize * 3}vh;
            display: block;
            opacity: 0;
            pointer-events: none;
            transition: 500ms ease;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:hover + div,
          ${selectors.commentForm} > div:last-child > div:first-child > div + div:hover/*(Twitterアカウントパネル)*/{
            opacity: 1;
            pointer-events: auto;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > div:first-child > div > div,
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > div:first-child > div > div > img/*アイコン*/{
            width: ${configs.lc_fontsize * 3}vh !important;
            height: ${configs.lc_fontsize * 3}vh !important;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > div:last-child/*アカウント情報*/{
            padding: .1vw .5vw;
            bottom: 0;
          }
          ${selectors.commentForm} > div:last-child > div:first-child > div:nth-child(2) > p:last-child/*ログアウト*/{
            padding: .1vw .2vw;
            position: absolute;
            bottom: 0;
            right: 0;
          }
          /* 新着コメント表示ボタン */
          ${selectors.newCommentsButton}{
            background: rgba(81,195,0,${configs.l_overlay ? configs.lb_opacityHover : 1}) !important;
            border: none;
            padding: 0;
            line-height: 3em;
            height: 0;/*デフォルトで非表示*/
            overflow: hidden;
            transition: height 500ms ease;
          }
          ${selectors.newCommentsButton}.shown{
            height: 3em;
          }
          /* 新着コメントのスライドダウン */
          ${selectors.newComments}{/*jsでやる*/
          }
          ${selectors.newComments} > div{
            will-change: transform;/*アベマが公式に指定すべきでは*/
          }
          html:not(.active) ${selectors.commentPane}:not(:hover) ${selectors.newComments} > div
          html:not(.active) ${selectors.commentPane}:not(:hover) ${selectors.newComments}{
            ${configs.l_hide ? 'transition: none !important;' : ''}/*画面外に隠れてるときはCPU負荷を下げる*/
          }
          /* コメント一覧 */
          /* セレクタがNGワード登録フォームと合致しないように気を付ける */
          ${selectors.board} */*リセット*/{
            padding: 0;
            margin: 0;
          }
          ${selectors.board} > div > span/*「まだ投稿がありません」のあやうい判定*/{
            margin-left: 20%;
            display: block;
          }
          ${selectors.comment}{
            padding: 0 .75vw;
          }
          ${selectors.comment} > div{
            flex-wrap: wrap;/*NGワード登録フォームの配置用*/
          }
          ${selectors.comment}:not(.blockform) > div[class*=" "]/*自分が投稿したコメントのあやうい判定*/{
            background: rgba(255,255,255,.125);
            padding: 0 .75vw;/*hack*/
            margin: 0 -.75vw;
          }
          ${selectors.board} div:not([id]) > p/*コメント*/,
          ${selectors.board} div:not([id]) > p + div/*経過時間・ブロックボタン*/{
            margin: ${configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh 0;
            line-height: ${1 + configs.lc_linemargin};
          }
          ${selectors.board} div:not([id]) > p/*コメント*/{
            word-wrap: break-word;
          }
          ${selectors.board} div:not([id]) > p + div/*経過時間・ブロックボタン*/{
            display: ${(configs.l_showtime) ? 'block' : 'none'};
            filter: opacity(75%);
            width: 4em;/*00秒前*/
            white-space: nowrap;
          }
          ${selectors.board} div:not([id]) > p + div > button/*ブロックボタン*/{
            padding: ${configs.lc_margin / 2}em  ${configs.lc_margin}em;
            margin: -${configs.lc_margin / 2}em -${configs.lc_margin}em;
          }
          ${selectors.board} div:not([id]) > p + div > button/*ブロックボタン*/ > svg{
            width: ${configs.lc_fontsize}vh;
            max-width: ${configs.lc_fontsize}vh;/*max指定しておけばword-break-allしなくてすむ*/
            height: ${configs.lc_fontsize}vh;
          }
          /* アベマ公式ブロック */
          ${selectors.comment}{
            cursor: pointer;
          }
          ${selectors.comment}.blockform{
            background: rgba(255,255,255,.25);
            border-bottom: 1px solid transparent;/*マージンの相殺を回避する*/
          }
          ${selectors.comment} form{
            height: 0;
            overflow: hidden;
            transition: height 500ms ease;
          }
          ${selectors.comment}.blockform form{
            height: calc(${configs.lc_fontsize * 2 + configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) * 1.5}vh + .5em + .75vw);/*アニメーションのためにキッチリ計算*/
          }
          ${selectors.comment} form > div:first-child > *{
            white-space: nowrap;
            overflow: hidden;
            margin: 0 0 ${configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh;
          }
          ${selectors.comment} form > div:first-child > p/*ブロックします*/{
            flex: 1;
          }
          ${selectors.comment} form > div:first-child > button/*キャンセル*/{
            text-align: right;
            width: 5em;
          }
          ${selectors.comment} form > div:last-child{
            margin-bottom: .75vw;
          }
          ${selectors.comment} form > div:last-child > div/*select*/{
            height: auto;
          }
          ${selectors.comment} form > div:last-child > div > span/*つまみ*/{
            right: .5em;
            top: calc(${configs.lc_fontsize / 2 + configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh - .325em);
            border-width: 0.5em;
            border-top-width: .75em;
          }
          ${selectors.comment} form > div:last-child > div > select{
            border-radius: .2em 0 0 .2em;
            padding: .25em 0;
            height: auto;
            border: none;
          }
          ${selectors.comment} form > div:last-child > div > select > option{
            padding: 0;
            margin: ${configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh 0;
          }
          ${selectors.comment} form > div:last-child > button{
            border-radius: 0 .2em .2em 0;
            padding: 0 1em;
          }
          ${selectors.comment} form > div:last-child > button > span{
            margin: ${configs.lc_fontsize * (configs.lc_margin - configs.lc_linemargin) / 2}vh 0;
            line-height: 1.5;
          }
          ${selectors.comment}:hover button[title="ブロック"] svg{
            fill: #f0163a;
          }
          ${selectors.board} div:not([id]) > form select,
          ${selectors.board} div:not([id]) > form select > option{
            color: black !important;
            background: white !important;
          }
          /* コメント一覧のスクロールバー */
          ${selectors.commentPane} > div > div{
            overflow-y: scroll;
            margin-right: -${getScrollbarWidth()}px;/*スクロールバーを隠す*/
            transition: margin-right 0ms;
          }
          /* 上下ナビゲーションの表示非表示 */
          ${selectors.header}{
            background: transparent;/*hover用paddingを持たせたいのでbackgroundはdivに移譲*/
            height: auto;
            padding-bottom: ${configs.header_height}px;
            transform: translateY(calc(-100% + ${configs.header_height}px)) !important;/*隠れているときもマウスオーバー領域を確保する*/
            visibility: visible !important;
            z-index: ${configs.header_zIndex};
            transition: ${configs.nav_transitionDelay};
          }
          ${selectors.header} > div{
            height: ${configs.header_height}px;
          }
          html.active ${selectors.commentPane} > div{
            padding-top: ${configs.header_height}px;/*右コメント一覧を映像に重ねたせいで上部ナビゲーションと重なるのを避ける*/
          }
          ${selectors.footer}{
            transform: translateY(calc(100% - ${configs.footer_height}px));/*隠れているときもマウスオーバー領域を確保する*/
            padding-top: ${configs.footer_height}px;
            z-index: ${configs.footer_zIndex};
            visibility: visible !important;
            transition: ${configs.nav_transitionDelay};
          }
          html:not(.active) ${selectors.footer}:not(:hover) > div > *{
            bottom: 0;/*フルスクリーンボタンと音量ボタンが突然消えないようにアベマが指定すべき値*/
          }
          html.click ${selectors.header},
          html.click ${selectors.footer}{
            transition: ${configs.nav_transition};
          }
          ${selectors.header}:hover,
          html.active ${selectors.header}{
            padding-bottom: ${configs.header_height * (1/2)}px;
            z-index: 11;
          }
          ${selectors.footer}:hover,
          html.active ${selectors.footer}{
            padding-top: ${configs.footer_height * (1/2)}px;
          }
          ${selectors.header}:hover,
          html.active ${selectors.header},
          ${selectors.footer}:hover,
          html.active ${selectors.footer}{
            transform: translateY(0%) !important;
          }
          html.active ${selectors.header},
          html.active ${selectors.footer}{
            padding-top: 0;
            padding-bottom: 0;
          }
          ${selectors.footer} > div > div:last-child > div:first-child:hover{/*ここにだけ追加して背景色を指定してるのはアベマのミスだろう*/
            background: transparent;
          }
          /* 上下ナビゲーションの透過 */
          ${selectors.header} > div,/*上部(hover用padding付き透明ラッパに包みたいのでdivに適用)*/
          ${selectors.header} button + div > div,/*その他ドロップダウン*/
          ${selectors.footer} > div > div:last-child/*下部*/{
            background: rgba(0,0,0,${configs.n_opacity}) !important;
            transition: 500ms ease;
          }
          ${selectors.header}:hover > div,
          ${selectors.header} button + div > div:hover,
          ${selectors.footer} > div > div:last-child:hover{
            background: rgba(0,0,0,${configs.n_opacityHover}) !important;
          }
          ${selectors.footer} > div > div:last-child{
            border-top: none;
          }
          ${selectors.programButton} div{/*チャンネル画像の背景が透過されていないアベマの仕様に対応*/
            background: transparent !important;
          }
          /* ブラウザ警告の透過 */
          ${selectors.caution}{
            opacity: ${configs.n_opacity};
          }
          ${selectors.caution},
          ${selectors.caution} *{
            color: white;
            background: transparent;
          }
          /* 通知を受け取るボタン・視聴数・ローディングの表示非表示 */
          ${selectors.notice}{
            transition: ${configs.nav_transition};/*常に遅延なし*/
          }
          html.comment ${selectors.notice}[class*=" "]/*デフォルトのクラスに表示用のクラスが追加された場合*/{
            right: ${!configs.l_hide && configs.l_overlay ? configs.l_width : '0'}%;
            bottom: ${configs.footer_height}px !important;
            transform: translate(-.75vw, -.75vw);
          }
          html.comment ${selectors.commentPane}:hover ~ ${selectors.screen} ${selectors.notice}[class*=" "]{/*たぶんcommentPaneの階層変更の影響で無効*/
            right: ${configs.l_hide && configs.l_overlay ? configs.l_width : '0'}%;
          }
          ${selectors.audienceTop}{/*基準親要素*/
            top: 0;
            width: 100% !important;
            overflow: hidden;
          }
          ${selectors.audience}{
            top: ${configs.header_height}px;
            right: 0%;
            transform: translate(100%, .75vw);
            visibility: visible;
            transition: ${configs.nav_transition};/*常に遅延なし*/
          }
          ${selectors.audience}:hover,/*コメント一覧がない場合*/
          html.active ${selectors.audience}{
            transform: translate(-.75vw, .75vw);
          }
          html.comment ${selectors.audience}:hover,/*コメント一覧が表示されている場合*/
          html.comment.active ${selectors.audience}{
            right: ${configs.l_overlay ? configs.l_width : '0'}%;
          }
          ${selectors.loading}{
            transform: translateY(${configs.footer_height}px);
          }
          /* 通知を受け取るボタン・視聴数・ローディングの透過 */
          ${selectors.notice} > button,
          ${selectors.audience}{
            background: rgba(0,0,0,${configs.n_opacity}) !important;
            transition: 500ms ease;
            pointer-events: auto;
          }
          ${selectors.notice} > button:hover,
          ${selectors.audience}:hover{
            background: rgba(0,0,0,${configs.n_opacityHover}) !important;
          }
          html.comment ${selectors.notice} > button{
            border-right: 1px solid #444;
          }
          ${selectors.screen}/*視聴数をマウスオーバーにちゃんと反応させる工夫*/{
            z-index: ${configs.screen_zIndex};
            pointer-events: none;
          }
          /* 裏番組一覧の表示非表示 */
          ${selectors.channelPane}{
            z-index: ${configs.channelPane_zIndex};
            transform: translateX(100%);
          }
          html.channel ${selectors.channelPane}{
            transform: translateX(0);
          }
          html:not(.channel) ${selectors.channelPane} [role="progressbar"] *{
            animation: none;/*画面外のローディングアニメーションにCPUを消費するアベマの悲しい仕様を上書き*/
          }
          /* 裏番組一覧の透過 */
          ${selectors.channelPane} > div{
            background: rgba(0,0,0,${configs.n_opacityHover});
          }
          ${selectors.channelPane} > div > a{
            background: transparent;
          }
          ${selectors.channelPane} > div > a:hover{
            background: rgba(34,34,34,${configs.n_opacityHover});
          }
          ${selectors.channelPane} *{
            color: white;
          }
          /* 番組情報の表示非表示 */
          ${selectors.programPane}{
            z-index: ${configs.programPane_zIndex};
            transform: translateX(100%);
          }
          html.program ${selectors.programPane}{
            transform: translateX(0);
          }
          /* 番組情報の透過 */
          ${selectors.programPane}{
            color: white;
            background: rgba(0,0,0,${configs.n_opacityHover});
            transition: 500ms ease;
          }
          ${selectors.programPane} svg > use:not([*|href*="_rect.svg"]){/*rectは赤背景*/
            fill: white;
          }
          /* ボタン共通 */
          ${selectors.channelButtons} button *,
          ${selectors.commentButton} *,
          ${selectors.programButton} *{
            pointer-events: none;/*クリックイベント発生箇所を親のボタン要素に統一する*/
          }
          #${SCRIPTNAME}-ng-button svg,
          #${SCRIPTNAME}-config-button svg{
            fill: white;
            vertical-align: middle;
          }
          ${selectors.footer} > div > *:not(:last-child)/*各ボタン*/{
            transition: ${configs.nav_transitionDelay};
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));/*白い背景で見にくいアベマの悲しい仕様を回避*/
            padding: 30px 15px 15px;/*クリック判定範囲を広くしてあげる*/
            margin: -30px -15px -15px;
          }
          html.click ${selectors.footer} > div > *:not(:last-child)/*各ボタン*/{
            transition: ${configs.nav_transition};
          }
          /* 裏番組一覧・チャンネル切り替えボタン */
          ${selectors.channelButtons}{
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));/*白い背景で見にくいアベマの悲しい仕様を回避*/
            transform: translate(calc(100% - ${configs.channelButtons_size}px), -50%);
            padding: ${configs.channelButtons_size * (1/2)}px 0 ${configs.channelButtons_size * (1/2)}px ${configs.channelButtons_size}px;/*隠れているときもサイズ3/4まではマウスオーバー領域を確保する*/
            transition: ${configs.nav_transitionDelay};/*アベマの指定漏れ?*/
            z-index: ${configs.channelButtons_zIndex};
          }
          html.click ${selectors.channelButtons}{
            transition: ${configs.nav_transition};
          }
          ${selectors.channelButtons}:hover,
          html.active ${selectors.channelButtons}{
            padding: ${configs.channelButtons_size * (1/2)}px 0 ${configs.channelButtons_size * (1/2)}px ${configs.channelButtons_size * (1/2)}px;
            transform: translate(0%, -50%);
          }
          html.ng ${selectors.channelButtons}/*NGワード登録中は控えて出しゃばらない*/{
            padding: 0;
            transform: translate(100%, -50%);
          }
          ${selectors.footer}:hover ~ ${selectors.channelButtons}/*フッタ操作中は出しゃばらない*/{
            z-index: 0;
          }
          /* ボリュームボタン */
          ${selectors.VolumeController} > div > div::after{/*一瞬フォーカスが外れるアベマの悲しい仕様を回避*/
            border-width: 16px 12px 16px;
            bottom: 8px;
          }
          /* コメントボタン */
          ${selectors.commentButton}{
            transition: 500ms ease;/*アベマの指定漏れ?*/
          }
          html.comment.active ${selectors.commentButton} svg,
          html.comment ${selectors.footer}:hover ${selectors.commentButton} svg,
          ${selectors.commentPane}.keep ${selectors.commentButton} svg{
            animation: spin 1s infinite alternate cubic-bezier(.45,.05,.55,.95)/*sin*/;
          }
          @keyframes spin{/*CPU食うので注意*/
            from{
              transform: scaleX(1);
            }
            to{
              transform: scaleX(-1);
            }
          }
          /* 登録NGワード一覧ボタン */
          #${SCRIPTNAME}-ng-button{
            right: 125px;
          }
          /* 設定ボタン */
          #${SCRIPTNAME}-config-button{
            right: 175px;
          }
          /* NGワード登録フォーム */
          #${SCRIPTNAME}-ng-form{
            border-radius: .5vw;
            margin-bottom: .75vw;/*お試しNGワードでハイライトされた場合に内包されるように*/
            width: 100%;
            background: rgba(32,32,32,${configs.l_overlay ? configs.lb_opacity : 1});
            height: calc(${configs.lc_fontsize}vh + 2 * ${configs.lc_fontsize * 2}vh + 4 * .5vw);/*アニメーションのためにキッチリ計算*/
            overflow: hidden;
            transition: 500ms ease;
          }
          #${SCRIPTNAME}-ng-form.hidden{
            height: 0;
            margin-bottom: 0;
          }
          #${SCRIPTNAME}-ng-form h1,
          #${SCRIPTNAME}-ng-form p{
            color: white;
            width: auto;
            margin: .5vw;
            display: flex;
          }
          #${SCRIPTNAME}-ng-form h1{
            line-height: ${configs.lc_fontsize}vh;
          }
          #${SCRIPTNAME}-ng-form p{
            line-height: ${configs.lc_fontsize * 2}vh;
          }
          #${SCRIPTNAME}-ng-form h1 span{
            flex-grow: 1;
          }
          #${SCRIPTNAME}-ng-form h1 button.list{
            width: ${configs.lc_fontsize * 2}vh;
            padding: ${configs.lc_fontsize / 2}vh 0;
            margin: -${configs.lc_fontsize / 2}vh 0;
          }
          #${SCRIPTNAME}-ng-form h1 button.list svg{
            vertical-align: top;
            width: ${configs.lc_fontsize}vh;
            height: ${configs.lc_fontsize}vh;
            fill: white;
          }
          #${SCRIPTNAME}-ng-form button.help{
            width: ${configs.lc_fontsize * 2}vh;
            margin-left: .5vw;
            background: rgba(0,0,0,${configs.lb_opacity});
            border-radius: .25vw;
          }
          #${SCRIPTNAME}-ng-form p.word input{
            color: white;
            border: none;
            border-radius: .25vw;
            background: rgba(0,0,0,${configs.lb_opacity});
            height: ${configs.lc_fontsize * 2}vh;
            padding: 0 .5vw;
            width: 50%;
            flex-grow: 1;
          }
          #${SCRIPTNAME}-ng-form p.type{
            border-radius: .25vw;
            overflow: hidden;
            display: flex;
          }
          #${SCRIPTNAME}-ng-form p.type button{
            color: white;
            font-weight: bold;
            width: 100%;
            margin-left: 1px;
            flex-grow: 1;
            height: ${configs.lc_fontsize * 2}vh;
          }
          #${SCRIPTNAME}-ng-form p.type button.trial{
            margin-left: 0;
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          #${SCRIPTNAME}-ng-form p.type button.for24h,
          #${SCRIPTNAME}-ng-form p.type button.forever{
            background: rgba(255,32,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          #${SCRIPTNAME}-ng-form p.type button.trial:hover,
          #${SCRIPTNAME}-ng-form p.type button.trial:focus{
            color: black;
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacityHover : 1});
          }
          #${SCRIPTNAME}-ng-form p.type button.for24h:hover,
          #${SCRIPTNAME}-ng-form p.type button.for24h:focus,
          #${SCRIPTNAME}-ng-form p.type button.forever:hover,
          #${SCRIPTNAME}-ng-form p.type button.forever:focus{
            background: rgba(255,32,32,${configs.l_overlay ? configs.lb_opacityHover : 1});
          }
          #${SCRIPTNAME}-ng-form h1 button.list:hover svg,
          #${SCRIPTNAME}-ng-form h1 button.list:focus svg,
          #${SCRIPTNAME}-ng-form p.word button.help:hover,
          #${SCRIPTNAME}-ng-form p.word button.help:focus{
            filter: brightness(.5);
          }
          /* NGワードコメント */
          ${selectors.comment}.ng-trial{
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacityHover : .75});
            border-bottom: 1px solid transparent;/*マージンの相殺を回避する*/
            cursor: pointer;
          }
          ${selectors.comment}.ng-trial:hover{
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          ${selectors.comment}.ng-trial > *:first-child/*NGワード登録フォームや公式ブロックには適用しない*/{
            pointer-events: none;/*イベントはcommentで発生させる*/
          }
          ${selectors.comment}.ng-deleted{
            display: none;
          }
          /* パネル共通 */
          #${SCRIPTNAME}-panels div.panel{
            position: absolute;
            width: 360px;
            max-height: 100%;/*小さなウィンドウに対応*/
            overflow: auto;
            left: 50%;
            bottom: 50%;
            transform: translate(-50%, 50%);
            z-index: ${configs.panel_zIndex};
            background: rgba(0,0,0,.75);
            transition: ${configs.nav_transition};
            padding: 5px 0;
          }
          #${SCRIPTNAME}-panels div.panel.hidden{
            bottom: 0;
            transform: translate(-50%, 100%) !important;
          }
          #${SCRIPTNAME}-panels h1,
          #${SCRIPTNAME}-panels h2,
          #${SCRIPTNAME}-panels h3,
          #${SCRIPTNAME}-panels h4,
          #${SCRIPTNAME}-panels legend,
          #${SCRIPTNAME}-panels ul,
          #${SCRIPTNAME}-panels dt,
          #${SCRIPTNAME}-panels dd,
          #${SCRIPTNAME}-panels code,
          #${SCRIPTNAME}-panels p{
            color: rgba(255,255,255,1);
            font-size: 14px;
            padding: 2px 10px;
            line-height:20px;
          }
          #${SCRIPTNAME}-panels header{
            display: flex;
          }
          #${SCRIPTNAME}-panels header h1{
            flex: 1;
          }
          #${SCRIPTNAME}-panels > div.panel > p.buttons{
            text-align: right;
            padding: 5px 10px;
          }
          #${SCRIPTNAME}-panels > div.panel > p.buttons button{
            width: 120px;
            padding: 5px 10px;
            margin-left: 10px;
            border-radius: 5px;
            color: rgba(255,255,255,1);
            background: rgba(64,64,64,1);
            border: 1px solid rgba(255,255,255,1);
          }
          #${SCRIPTNAME}-panels > div.panel > p.buttons button.primary{
            font-weight: bold;
            background: rgba(0,0,0,1);
          }
          #${SCRIPTNAME}-panels > div.panel > p.buttons button:hover,
          #${SCRIPTNAME}-panels > div.panel > p.buttons button:focus{
            background: rgba(128,128,128,.75);
          }
          #${SCRIPTNAME}-panels .template{
            display: none !important;
          }
          #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(1){
            transform: translate(-100%, 50%);
          }
          #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(2){
            transform: translate(0%, 50%);
          }
          #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(1){
            transform: translate(-150%, 50%);
          }
          #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(3){
            transform: translate(50%, 50%);
          }
          /* NGワード一覧 */
          #${SCRIPTNAME}-ng-list button.help{
            color: white;
            width: 20px;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-ng-list button.help:hover,
          #${SCRIPTNAME}-ng-list button.help:focus{
            filter: brightness(.5);
          }
          #${SCRIPTNAME}-ng-list p.sort{
            width: 80%;
            height: 20px;
            padding: 0;
            margin: 5px auto;
            border-radius: 5px;
            overflow: hidden;
            display: flex;
          }
          #${SCRIPTNAME}-ng-list p.sort.disabled{
            filter: brightness(.5);
            pointer-events: none;
          }
          #${SCRIPTNAME}-ng-list p.sort input{
            display: none;
          }
          #${SCRIPTNAME}-ng-list p.sort label{
            color: white;
            background: rgba(128,128,128,.25);
            font-size: 10px;
            text-align: center;
            width: 100%;
            margin-left: 1px;
          }
          #${SCRIPTNAME}-ng-list p.sort label:first-of-type{
            margin-left: 0;
          }
          #${SCRIPTNAME}-ng-list p.sort input + label::after{
            font-size: 75%;
            vertical-align: top;
            content: " ▼";
          }
          #${SCRIPTNAME}-ng-list p.sort input.reverse + label::after{
            content: " ▲";
          }
          #${SCRIPTNAME}-ng-list p.sort input:checked + label,
          #${SCRIPTNAME}-ng-list p.sort label:hover,
          #${SCRIPTNAME}-ng-list p.sort label:focus{
            background: rgba(128,128,128,.75);
          }
          #${SCRIPTNAME}-ng-list ul{
            max-height: calc(${window.innerHeight}px - (5px + 24px + 30px + 42px + 5px) - 20px);
            overflow-y: auto;
          }
          #${SCRIPTNAME}-ng-list ul > li{
            padding: 2px 10px;
            display: flex;
          }
          #${SCRIPTNAME}-ng-list p.word,
          #${SCRIPTNAME}-ng-list p.words{
            padding: 0;
            flex: 1;
          }
          #${SCRIPTNAME}-ng-list p.word input,
          #${SCRIPTNAME}-ng-list p.words textarea{
            font-size: 12px;
            width: 100%;
          }
          #${SCRIPTNAME}-ng-list p.word input{
            height: 20px;
          }
          #${SCRIPTNAME}-ng-list p.words textarea{
            height: 40px;
            resize: vertical;
          }
          #${SCRIPTNAME}-ng-list p.type{
            height: 20px;
            border-radius: 5px;
            overflow: hidden;
            padding: 0;
            margin-left: 10px;
            flex: 1;
            display: flex;
          }
          #${SCRIPTNAME}-ng-list p.type input{
            display: none;
          }
          #${SCRIPTNAME}-ng-list p.type label{
            text-align: center;
            font-size: 10px;
            font-weight: bold;
            width: 100%;
            margin-left: 1px;
          }
          #${SCRIPTNAME}-ng-list p.type label.trial{
            margin-left: 0;
            background: rgba(255,224,32,.25);
          }
          #${SCRIPTNAME}-ng-list p.type label.for24h,
          #${SCRIPTNAME}-ng-list p.type label.forever{
            background: rgba(255,32,32,.25);
          }
          #${SCRIPTNAME}-ng-list p.type input:checked + label.trial,
          #${SCRIPTNAME}-ng-list p.type label.trial:hover,
          #${SCRIPTNAME}-ng-list p.type label.trial:focus{
            color: black;
            background: rgba(255,224,32,.75);
          }
          #${SCRIPTNAME}-ng-list p.type input:checked + label.for24h,
          #${SCRIPTNAME}-ng-list p.type label.for24h:hover,
          #${SCRIPTNAME}-ng-list p.type label.for24h:focus,
          #${SCRIPTNAME}-ng-list p.type input:checked + label.forever,
          #${SCRIPTNAME}-ng-list p.type label.forever:hover,
          #${SCRIPTNAME}-ng-list p.type label.forever:focus{
            background: rgba(255,32,32,.75);
          }
          #${SCRIPTNAME}-ng-list p.type label.remove{
            background: rgba(128,128,128,.25);
          }
          #${SCRIPTNAME}-ng-list p.type input:checked + label.remove,
          #${SCRIPTNAME}-ng-list p.type label.remove:hover,
          #${SCRIPTNAME}-ng-list p.type label.remove:focus{
            background: rgba(128,128,128,.75);
          }
          #${SCRIPTNAME}-ng-list li.add p.type label.remove{
            visibility: hidden;
          }
          #${SCRIPTNAME}-ng-list input + label{
            cursor: pointer;
          }
          /* NGヘルプパネル */
          #${SCRIPTNAME}-ng-help h2{
            margin-top: 10px;
          }
          #${SCRIPTNAME}-ng-help dl{
            display: flex;
            flex-wrap: wrap;
          }
          #${SCRIPTNAME}-ng-help dl dt{
            width: 110px;
            margin: 2.5px 0 2.5px 10px;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-ng-help dl dt code{
            padding:0 5px;
          }
          #${SCRIPTNAME}-ng-help dl dd{
            width: 240px;
            margin: 2.5px 0;
          }
          /* 設定パネル */
          #${SCRIPTNAME}-config-panel fieldset p{
            padding-left: calc(10px + 1em);
          }
          #${SCRIPTNAME}-config-panel fieldset p:hover{
            background: rgba(255,255,255,.25);
          }
          #${SCRIPTNAME}-config-panel label{
            display: block;
          }
          #${SCRIPTNAME}-config-panel input{
            width: 80px;
            height: 20px;
            position: absolute;
            right: 10px;
          }
        </style>
      `,
    },
  };
  class Storage{
    static key(key){
      return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
    }
    static save(key, value, expire = null){
      key = Storage.key(key);
      localStorage[key] = JSON.stringify({
        expire: expire,
        value: value,
      });
    }
    static read(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.value === undefined) return data;
      if(data.expire === undefined) return data;
      if(data.expire === null) return data.value;
      if(data.expire < Date.now()) return localStorage.removeItem(key);
      return data.value;
    }
  }
  let $ = function(s){return document.querySelector(s)};
  let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  let sequence = function(){
    let chain = [], defer = function(callback, delay, ...params){(delay) ? setTimeout(callback, delay, ...params) : animate(callback, ...params)};
    for(let i = arguments.length - 1, delay = 0; 0 <= i; i--, delay = 0){
      if(typeof arguments[i] === 'function'){
        for(let j = i - 1; typeof arguments[j] === 'number'; j--) delay += arguments[j];
        let f = arguments[i], d = delay, callback = chain[chain.length - 1];
        chain.push(function(pass){defer(function(ch){ch ? ch(f(pass)) : f(pass);}, d, callback)});/*nearly black magic*/
      }
    }
    chain[chain.length - 1]();
  };
  let observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  let createElement = function(html){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  let getScrollbarWidth = function(){
    let div = document.createElement('div');
    document.body.appendChild(div);
    div.style.overflowY = 'scroll';
    let clientWidth = div.clientWidth;
    div.style.overflowY = 'hidden';
    let offsetWidth = div.offsetWidth;
    document.body.removeChild(div);
    return offsetWidth - clientWidth;
  };
  let normalize = function(string){
    return string.replace(/[!-~]/g, function(s){
      return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
    }).replace(normalize.RE, function(s){
      return normalize.KANA[s];
    }).replace(/ /g, ' ').replace(/~/g, '〜');
  };
  normalize.KANA = {
    ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ',
    ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ',
    ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド',
    バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ',
    パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ',
    ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ',
    ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ',
    カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ',
    サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ',
    タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト',
    ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ',
    ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ',
    マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ',
    ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ',
    ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ',
    ワ:'ワ', ヲ:'ヲ', ン:'ン',
    ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ',
    ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ',
    "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・',
  };
  normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g');
  let log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let stack = new Error().stack, callers = stack.match(/^([^/<]+(?=<?@))/gm) || stack.match(/[^. ]+(?= \(<anonymous)/gm) || [];
    console.log(
      SCRIPTNAME + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + stack.match(/:[0-9]+:[0-9]+/g)[1].split(':')[1],/*LINE*/
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '')  + '()',
      ...arguments
    );
  };
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();