Greasy Fork

Greasy Fork is available in English.

AbemaTV Screen Comment Scroller

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

当前为 2017-11-09 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ScreenCommentScroller';
  const DEBUG = false;/*
  [update]

  [to do]
  結局テキストトランジションなら軽い可能性?縁取り再現はあきらめる。

  画質落とし挑戦
  最大同時表示数は右の一覧コメにも反映…できる?そもそも需要ある?

  >コメント欄がロードしてドバっと表示、ロードしてドバっと表示の繰り返しなのがダメだわ。
  >自分のコメは表示、他人のコメはPC側で間引き表示にしてほしい。 
  コメントをまとめて取得する(=スクロールのタイミングで表示する) 設定値も作ろう!!
  負荷低減のためトランジションは100msで。

  番組変わった時点で何かを開いてるとコメが閉じられてしまう?

  通知受け取るボタンの位置はz-indexどうにもならんのかな…
    最悪左に移しちゃえばいいのかな>>ナビゲーション要素は右側に統一されてる
    通知受け取るボタンが番組開始後も取り残されることがある?

  コメント投稿後の再登場を回避できないか>>再登場する仕様解消した?

  Transitionと全面Canvasはどちらが未来なのか
    Transitionに統一しつつTransition特有のバグをつぶすか
      背景タブ時に重なりまくる問題

  番組表と通知
  アドオン拡張化

  [not to do]
  新着コメント緑ボタン後の表示は現状簡単にはアニメーションさせられない
  画面外だといつの間にかコメントが止まるとかそもそも発動しないのはアベマ自体のバグ
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const CONFIGS = [
    /* スクロールコメント */
    {KEY: 'maxlines',        TYPE: 'int',   DEFAULT: 10   },/*最大行数(文字サイズ連動)*/
    {KEY: 'linemargin',      TYPE: 'float', DEFAULT: 0.20 },/*行間(比率)*/
    {KEY: 'transparency',    TYPE: 'int',   DEFAULT: 50   },/*透明度(%)*/
    {KEY: 'owidth',          TYPE: 'float', DEFAULT: 0.05 },/*縁取りの太さ(比率)*/
    {KEY: 'duration',        TYPE: 'float', DEFAULT: 5.00 },/*横断にかける秒数*/
    {KEY: 'maxcomments',     TYPE: 'int',   DEFAULT: 100  },/*最大同時表示数*/
    {KEY: 'canvas',          TYPE: 'bool',  DEFAULT: 0    },/*全面Canvasで描画(高性能PC向け)*/
    {KEY: 'fps',             TYPE: 'int',   DEFAULT: 60   },/*全面Canvasでの秒間描画コマ数*/
    /* 一覧コメント */
    {KEY: 'l_hide',          TYPE: 'bool',  DEFAULT: 1    },/*操作していない時は画面外に隠す*/
    {KEY: 'l_overlay',       TYPE: 'bool',  DEFAULT: 1    },/*映像に重ねる*/
    {KEY: 'l_showtime',      TYPE: 'bool',  DEFAULT: 1    },/*投稿時刻を表示する*/
    {KEY: 'l_width',         TYPE: 'float', DEFAULT: 16.5 },/*横幅(%)*/
    {KEY: 'lc_maxlines',     TYPE: 'int',   DEFAULT: 30   },/*最大行数(文字サイズ連動)*/
    {KEY: 'lc_linemargin',   TYPE: 'float', DEFAULT: 0.50 },/*改行されたコメントの行間(比率)*/
    {KEY: 'lc_margin',       TYPE: 'float', DEFAULT: 1.65 },/*コメント同士の間隔(比率)*/
    {KEY: 'lc_transparency', TYPE: 'int',   DEFAULT: 25   },/*文字の透明度(%)*/
    {KEY: 'lb_transparency', TYPE: 'int',   DEFAULT: 75   },/*背景の透明度(%)*/
    /* アベマのナビゲーション */
    {KEY: 'n_clickonly',     TYPE: 'bool',  DEFAULT: 0    },/*画面クリック時のみ表示する*/
    {KEY: 'n_delay',         TYPE: 'float', DEFAULT: 4.00 },/*隠れるまでの時間(秒)*/
    {KEY: 'n_transparency',  TYPE: 'int',   DEFAULT: 50   },/*透明度(%)*/
  ];
  const PANELS = ['configPanel', 'ngList', 'ngHelp'];/*パネルの表示順*/
  const AINTERVAL = 7;/*AbemaTVのコメント取得間隔の仕様値*/
  const CANVASMARGIN = 10;/*canvas内に文字を確実に収めるための余裕*/
  /* サイト定義 */
  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 commentForm = $('form:not([role="search"])'); return (commentForm) ? site.use(commentForm.parentNode.parentNode.nextElementSibling) : 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'); for(let i = 0; buttons[i]; i++) site.use(buttons[i].parentNode); 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 wrapper = node.querySelector(selectors.board + ' > div:not([data-selector]) > div'); return (wrapper) ? site.use(node) && Array.from(wrapper.children).map(site.addedNode.comment) : null;},
      newComment: function(node){let commentText = node.querySelector(selectors.newComments + ' > div > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : null;},
      comment: function(node){let commentText = node.querySelector('div:not([data-selector]) > 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;},
    },
    get: {
      comments: function(newComments){return newComments.firstElementChild.children;},
      commentText: function(comment){return comment.firstElementChild.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, interval, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/
  let core = {
    /* 初期化 */
    initialize: function(){
      let previousUrl = '';
      /* 一度だけ */
      html = document.documentElement;
      core.config.read();
      core.ng.initialize();
      core.listenUserActions();
      window.addEventListener('resize', setTimeout.bind(null, core.modify, 100));
      /* URLの変化を見守る */
      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');
          /* テレビ視聴ページになった */
          }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);
        }
      }
      log("I'm Ready.");
      /* すべての要素が出揃っていたので */
      core.createCanvas();
      core.listenComments();
      core.ng.createButton();
      core.config.createButton();
      if(configs.canvas) core.scrollComments();
      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'){
          if(url !== location.href){/*チャンネル切り替え後の初回*/
            elements.commentButton.click();
            url = location.href;
          }else if(html.classList.contains('comment')){/*コメントを開いた状態で番組開始を迎えたとき*/
            setTimeout(function(){elements.commentButton.click()}, 1000);
            setTimeout(function(){elements.commentButton.click()}, 2000);
          }
        }
      }, {attributes: true});
    },
    /* テレビ視聴ページから離れたときに呼ぶ */
    gone: function(){
      if(elements.style) document.head.removeChild(elements.style);
      html.classList.remove(SCRIPTNAME);
    },
    /* キーボードとマウスイベントを見守る */
    listenUserActions: function(){
      let id;
      let timer = function(e){
        clearTimeout(id), id = setTimeout(function(){
          if(['input', 'textarea', 'button'].includes(document.activeElement.loaclName)) return;/*入力中はアクティブのまま*/
          html.classList.remove('active');
        }, configs.n_delay * 1000);
      };
      let activate = function(){
        if(!html.classList.contains('active')) html.classList.add('active');
        timer();
      };
      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;
        activate();
      });
      /* クリックを捉える */
      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;
              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});
              e.stopPropagation();
            }else{
              /* デフォルトのボタン動作が実行される */
            }
            return;
          case(elements.closer):
            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();
              case(html.classList.contains('comment')):
                core.ng.closeForm();/*NGフォームを開いているなら閉じる*/
              default:
                if(e.isTrusted){/*実クリックではコメントは閉じない*/
                  e.stopPropagation();
                  html.classList.toggle('active');
                  timer();
                }else{/*スクリプトのelements.closer.click()でのみ閉じる*/
                  html.classList.toggle('comment');
                  if(!configs.l_overlay) core.modify();
                }
                return;
            }
          default:
            return;/*デフォルトの動作に任せる*/
        }
      }, true);
      /* コメントペインを隠す設定でもコメント入力中は表示させる */
      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});
    },
    /* canvas作成 */
    createCanvas: function(){
      if(canvas) elements.screen.removeChild(canvas);
      if(configs.canvas){
        canvas = createElement(core.html.canvas());
        context = canvas.getContext('2d', {alpha: false});
      }else{
        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 sans-serif`;
      context.textBaseline = 'middle';
      context.fillStyle = 'white';
      context.strokeStyle = 'black';
      context.lineWidth = Math.round(canvas.fontsize * configs.owidth);
      context.lineJoin = 'round';
      canvas.topDelta = (configs.canvas) ? ((canvas.height / configs.maxlines) / 2) : (((canvas.fontsize * configs.linemargin) - context.lineWidth - CANVASMARGIN) / 2);/*canvasのtop計算に使用する*/
      if(configs.canvas){
        /* スクロールコメントの再計算 */
        for(let i=0; lines[i]; i++){
          for(let j=0; lines[i][j]; j++){
            lines[i][j].width = context.measureText(lines[i][j].text).width;
            lines[i][j].ppms = (canvas.width + lines[i][j].width) / (configs.duration * 1000);
            lines[i][j].top = Math.round(((canvas.height / configs.maxlines) * i) + canvas.topDelta);
          }
        }
        core.scrollComments();
      }
    },
    /* コメントの新規追加を見守る */
    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){
        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;
                  }
                }
                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]);
              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;
          }
        }
        if(ngFormIndex !== null) replacedComments[ngFormIndex].appendChild(elements.ngForm);
      });
    },
    /* 新着コメントを受け取ったときの処理 */
    receiveNewComments: function(newComments){
      let getDelay = function(text){
if(DEBUG && !text.endsWith) alert(text);/*text.endsWith is not a functionと言われたので*/
        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;
        }
      };
      /* コメントの継続取得に失敗したら自動復帰する */
      let recoverComment = function(interval, n = 10){
        return setTimeout(function(){
          if(!elements.board.children[n - 1]) return;
          if(getDelay(elements.board.children[n - 1].children[1].textContent) >= interval) return;
          if(!html.classList.contains('comment')) return;/*コメント表示中でない場合はなにもしない*/
          if(getComputedStyle(elements.commentButton).cursor === 'pointer'){
            setTimeout(function(){elements.commentButton.click()}, 1);
            setTimeout(function(){elements.commentButton.click()}, 2);
          }
        }, 1000 * interval);
      };
      clearTimeout(elements.board.shortTimer), elements.board.shortTimer = recoverComment(10);/*10件目が10秒以内なのに10秒以上コメがない*/
      clearTimeout(elements.board.longTimer), elements.board.longTimer = recoverComment(60);/*10件目が60秒以内なのに60秒以上コメがない*/
      /* コメントの取得間隔を計測する(AINTERVAL仕様の変更に備える) */
      let now = Date.now(), commentInterval = (now - parseInt(newComments.dataset.received)) / 1000 || AINTERVAL;
      newComments.dataset.received = now;/*datasetを使うことでnewCommentsがなくなるときはいっしょになくなる*/
      /* NGコメントをすぐ判定する */
      core.ng.expire();
      let filteredComments = Array.from(site.get.comments(newComments)).filter(core.ng.filter);
      /* スライドダウンアニメーションを上書きする */
      core.slideDownNewComments(newComments);
      /* 投稿経過時間に合わせた自然なばらつきでコメントを流すためのスケジュールを作る */
      let schedule = [];/*タイミングだけを格納する配列*/
      for(let i = 0; filteredComments[i]; i++){
        schedule.push(getDelay(filteredComments[i].lastElementChild.textContent));
      }
      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; filteredComments[i]; i--){
        window.setTimeout(function(){
          core.attachComment(filteredComments[i].firstElementChild.textContent);
        }, 1000 * schedule[i]);
      }
    },
    /* スライドダウンアニメーションを上書きする */
    slideDownNewComments: function(newComments){
      newComments.style.maxHeight = '0px';/*heightの上書き戦争を避けてmaxHeightが使えるのは幸運*/
      newComments.dataset.naturalHeight = getComputedStyle(newComments.firstElementChild).height;
      animate(function(){newComments.style.maxHeight = newComments.dataset.naturalHeight});
    },
    /* コメントが追加されるたびにスクロールキューに追加 */
    attachComment: function(text){
      if(configs.canvas && lines.map((line) => line.length).reduce((a, b) => a + b, 0) >= configs.maxcomments) return;
      else if(canvas.children.length >= configs.maxcomments) return;
      let scrollComment, c;
      let width = Math.round(context.measureText(text).width + context.lineWidth);
      let height = Math.round(canvas.fontsize + context.lineWidth + CANVASMARGIN);
      if(!configs.canvas){
        scrollComment = createElement(core.html.scrollComment(width, height));
        c = scrollComment.getContext('2d');
        c.font = `bold ${canvas.fontsize}px sans-serif`;
        c.textBaseline = context.textBaseline;
        c.fillStyle = context.fillStyle;
        c.strokeStyle = context.strokeStyle;
        c.lineWidth = context.lineWidth;
        c.lineJoin = context.lineJoin;
        let padding = Math.round(context.lineWidth / 2);
        let middle = Math.round(height / 2);
        c.strokeText(text, padding, middle);
        c.fillText(text, padding, middle);
      }
      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);/*終了時刻*/
      record.left = canvas.width;/*左端からの距離(初期描画位置)*/
      /* 追加されたコメントをどの行に流すかを決定する */
      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(!configs.canvas) scrollComment.dataset.former = JSON.stringify(lines[i][length - 1]);
//if(!configs.canvas) scrollComment.dataset.self = JSON.stringify(record);
            lines[i].push(record);
            if(!configs.canvas){
              scrollComment.style.top = record.top + 'px';
              canvas.appendChild(scrollComment);
              animate(function(){
                scrollComment.classList.add('scroll');
                scrollComment.addEventListener('transitionend', function(e){
                  canvas.removeChild(scrollComment);
                  lines[i].shift();
                }, {once: true});
              });
            }
            return;/*行に追加したら終了*/
          default:
            continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/
        }
      }
    },
    /* Canvas FPSタイマー駆動 */
    scrollComments: function(){
      /* アニメーション関連は極力浅いオブジェクトに */
      let width = canvas.width, height = canvas.height, fps = configs.fps;
      clearInterval(interval), interval = setInterval(function(){
        let now = Date.now();
        /* Canvas描画 */
        context.clearRect(0, 0, width, height);
        for(let i = 0, line; line = lines[i]; i++){
          for(let j = 0, comment; comment = line[j]; j++){
            /* 描画位置を計算 */
            comment.left = width - ((now - comment.start) * comment.ppms);
            /* 視認性を向上させるスクロール文字の縁取りは、幸いにもパフォーマンスにほぼ影響しない */
            context.strokeText(comment.text, comment.left, comment.top);
            context.fillText(comment.text, comment.left, comment.top);
          }
          if(line[0] && line[0].end < now) line.shift();
        }
      }, 1000 / fps);
    },
    /* NGワード */
    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 : 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){
          if(![e.target.dataset.selector, e.target.parentNode.dataset.selector].includes('comment')) return;
          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});
        });
      },
      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 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;
                elements.ngForm.targetComment.appendChild(elements.ngForm);
                slideDown();
              }, {once: true});
            }else{
              elements.ngForm.slidingUp = false;
              elements.ngForm.targetComment.appendChild(elements.ngForm);
              slideDown();
            }
          });
        };
        let slideDown = function(){
          elements.ngForm.slidingDown = true;
          if(elements.ngForm.parentNode !== elements.ngForm.targetComment) elements.ngForm.targetComment.appendChild(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*/
        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].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;
        }
        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].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.value < b.value);
            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.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].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') || {};
        /* 未定義項目をデフォルト値で上書きしていく */
        for(let i = 0, config; config = CONFIGS[i]; i++) if(configs[config.KEY] === undefined) configs[config.KEY] = config.DEFAULT;
      },
      save: function(new_config){
        configs = {};/*CONFIGSに含まれた設定値のみ保存する*/
        /* CONFIGSを元に文字列を型評価して値を格納していく */
        for(let i = 0, config; config = CONFIGS[i]; i++){
          /* 値がなければデフォルト値 */
          if(new_config[config.KEY] === ""){
            configs[config.KEY] = config.DEFAULT;
            continue;
          }
          switch(config.TYPE){
            case 'bool':
              configs[config.KEY] = (new_config[config.KEY]) ? 1 : 0;
              break;
            case 'int':
              configs[config.KEY] = parseInt(new_config[config.KEY]);
              break;
            case 'float':
              configs[config.KEY] = parseFloat(new_config[config.KEY]);
              break;
            case 'string':
            default:
              configs[config.KEY] = new_config[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++){
            if(input.type === 'checkbox') new_configs[input.name] = (input.checked) ? 1 : 0;
            else new_configs[input.name] = input.value;
          }
          core.config.save(new_configs);
          core.panel.close('configPanel')
          /* 新しい設定値で再スタイリング */
          core.addStyle();
          core.createCanvas();/*modify含む*/
        }, true);
        elements.configPanel.querySelector('input[name="canvas"]').addEventListener('click', function(e){
          let fps = elements.configPanel.querySelector('input[name="fps"]');
          fps.disabled = !fps.disabled;
        }, 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(elements[key] && e.key === 'Escape') core.panel.close(key);
          });
        }
      },
      close: function(key){
        elements[key].classList.add('hidden');
        elements[key].addEventListener('transitionend', function(){
          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) document.head.removeChild(elements.style);
      elements.style = style;
    },
    html: {
      canvas: () => `
        <canvas id="${SCRIPTNAME}-canvas"></canvas>
      `,
      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 class="mJ_u eJ_b eJ_e" height="20" role="img" width="20"><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>
            <p><label>全面Canvasで描画(高性能PC向け): <input type="checkbox" name="canvas"          value="${configs.canvas}"          ${configs.canvas      ? 'checked' : ''}></label></p>
            <p><label>全面Canvasでの秒間描画コマ数:   <input type="number"   name="fps"             value="${configs.fps}"             min="1"  max="240" step="1" ${configs.canvas ? '' : 'disabled'}></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 ${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)} */
          /* font-size:             ${configs.fontsize = (100 / configs.maxlines) / (1 + configs.linemargin)} (設定値の表現をわかりやすくする代償はここで支払う) */
          /* lc_font-size:          ${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} */
          /* canvas_zIndex:         ${configs.canvas_zIndex         =   3} */
          /* screen_zIndex:         ${configs.screen_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} */
          /* スクロールコメント */
          #${SCRIPTNAME}-canvas{
            z-index: ${configs.canvas_zIndex};
            pointer-events: none;
            position: absolute;
            top: 0;
            left: 0;
            opacity: 0;/*コメント非表示なら速やかに消える*/
            transition: opacity 500ms ease;
          }
          html.comment #${SCRIPTNAME}-canvas{
            opacity: ${configs.opacity};
          }
          #${SCRIPTNAME}-canvas > canvas{
            position: absolute;
            left: 100%;
            transform: translateX(0%);
            transition: left ${configs.duration}s linear, transform ${configs.duration}s linear;
            will-change: left, transform;
          }
          #${SCRIPTNAME}-canvas > canvas.scroll{
            left: 0%;
            transform: translateX(-100%);
          }
          /* 映像 */
          ${selectors.screen}{
            transition: 500ms ease;
          }
          ${selectors.screen} > div{
            width: 100% !important;
            height: 100% !important;
            transition: 500ms ease;
          }
          /* コメントペインの表示非表示 */
/*隠すと重ねるの整合性*/
          ${selectors.commentPane}{
            width: auto;
            padding-left: ${configs.l_hide ? configs.l_width : 0}vw;
            transform: translateX(100%);
            z-index: ${configs.commentPane_zIndex};
            transition: 500ms ease;
          }
          html.comment ${selectors.commentPane}{
            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 * (1/4) : 0}vw;/*隠れているときもマウスオーバー領域を確保する*/
          }
          ${selectors.commentPane} > div{
            width: ${configs.l_width}vw;
          }
          /* コメントペインの透過 */
          ${selectors.commentPane} > div{
            background: rgba(0,0,0,${configs.l_overlay ? configs.lb_opacity : 1});
            -webkit-mask-image: linear-gradient(black 50%, transparent);/*まだ-webkit取れない*/
            mask-image: linear-gradient(black 50%, transparent);
            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 ~ ${selectors.commentPane} > div,
          html.active ${selectors.commentPane} > div{
            height: 100%;/*gradientでtransitionが効かないのでheightで代用*/
          }
          ${selectors.commentPane},
          ${selectors.commentPane} *{
            color: rgba(255,255,255,${configs.l_overlay ? configs.lc_opacity : 1});
            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});
            border: none;
            padding: 0;
            line-height: 3em;
            height: 0;/*デフォルトで非表示*/
            overflow: hidden;
            transition: height 500ms ease;
          }
          ${selectors.newCommentsButton}.shown{
            height: 3em;
          }
          /* 新着コメントのスライドダウン */
          ${selectors.newComments}{
            transition: max-height 500ms cubic-bezier(.215,.61,.355,1) !important;/*アベマの仕様に合わせなきゃならんのでやや心もとないが*/
            will-change: height, max-height;/*アベマが公式に指定すべきでは*/
          }
          ${selectors.newComments} > div{
            will-change: transform;/*アベマが公式に指定すべきでは*/
          }
          html:not(.active) ${selectors.commentPane}:not(:hover) *{
            ${configs.l_hide ? 'transition: none;' : ''}/*画面外に隠れてるときはCPU負荷を下げる*/
          }
          /* コメント一覧 */
          /* (${selectors.comment}は後付けなのでスライドダウンアニメーションに影響する) */
          /* セレクタがNGワード登録フォームと合致しないように気を付ける */
          ${selectors.board}{
            margin: 0;
          }
          ${selectors.board} > div{
            padding: 0 .75vw;
          }
          ${selectors.board} > div/*newComments*/ > div > div/*comment*/{/*打ち消しハック*/
            padding: 0 .75vw;
            margin: 0 -.75vw;
          }
          ${selectors.comment}{
            flex-wrap: wrap;/*NGワード登録フォームの配置用*/
          }
          ${selectors.board} div:not([id]) > p/*コメント,経過時間*/{
            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:first-child/*コメント*/{
            width: ${(configs.l_showtime) ? 'calc(100% - 4em)' : '100%'};
            overflow-wrap: break-word;
          }
          ${selectors.board} div:not([id]) > p:nth-child(2)/*経過時間*/{
            display: ${(configs.l_showtime) ? 'block' : 'none'};
            filter: opacity(75%);
            width: 4em;/*00秒前*/
            white-space: nowrap;
          }
          /* コメント一覧のスクロールバー */
          ${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: 500ms ease;
          }
          ${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: 500ms ease;
          }
          html:not(.active) ${selectors.footer}:not(:hover) > div > *{
            bottom: 0;/*フルスクリーンボタンと音量ボタンが突然消えないようにアベマが指定すべき値*/
          }
          ${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: 500ms ease;
          }
          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);
          }
          ${selectors.audienceTop}{/*基準親要素*/
            top: 0;
            width: 100% !important;
            overflow: hidden;
          }
          ${selectors.audience}{
            top: ${configs.header_height}px;
            right: 0%;
            transform: translate(100%, .75vw);
            visibility: visible;
          }
          ${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: 500ms ease;
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));/*白い背景で見にくいアベマの悲しい仕様を回避*/
            padding: 30px 15px 15px;/*クリック判定範囲を広くしてあげる*/
            margin: -30px -15px -15px;
          }
          /* 裏番組一覧・切り替えボタン */
          ${selectors.channelButtons}{
            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: 500ms ease;/*アベマの指定漏れ?*/
            z-index:${configs.channelButtons_zIndex};
          }
          ${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.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{
            animation: spin 1s infinite alternate cubic-bezier(.45,.05,.55,.95)/*sin*/;
          }
          @keyframes spin{/*GPU処理されるはずなのに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ワード一覧 */
          #${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ワードコメント */
          ${selectors.comment}.ng-trial{
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacityHover : .75});
            cursor: pointer;
          }
          ${selectors.comment}.ng-trial:hover{
            background: rgba(255,224,32,${configs.l_overlay ? configs.lb_opacity : .5});
          }
          ${selectors.comment}.ng-trial > p{
            pointer-events: none;/*イベントはcommentで発生させる*/
          }
          ${selectors.comment}.ng-deleted{
            display: none;
          }
          /* パネル共通 */
          body{
            overflow: hidden;
          }
          .${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: 500ms ease;
            padding: 5px 0;
          }
          .${SCRIPTNAME}-panels div.panel.hidden{
            bottom: 0;
            transform: translate(-50%, 100%) !important;
          }
          .${SCRIPTNAME}-panels h1,
          .${SCRIPTNAME}-panels h2,
          .${SCRIPTNAME}-panels legend,
          .${SCRIPTNAME}-panels dl,
          .${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-help{
            width: 360px;
          }
          #${SCRIPTNAME}-ng-help h2{
            margin-top: 10px;
          }
          #${SCRIPTNAME}-ng-help dl{
            display: flex;
            flex-wrap: wrap;
          }
          #${SCRIPTNAME}-ng-help dl dt{
            width: 100px;
            margin: 2.5px 10px 2.5px 0;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-ng-help dl dt code{
            padding:0 5px;
          }
          #${SCRIPTNAME}-ng-help dl dd{
            width: 230px;
            margin: 2.5px 0;
          }
          /* 設定パネル */
          #${SCRIPTNAME}-config-panel{
            width: 360px;
          }
          #${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 input{
            width: 80px;
            height: 20px;
            position: absolute;
            right: 10px;
          }
          #${SCRIPTNAME}-config-panel p.license,
          #${SCRIPTNAME}-config-panel p.license a{
            font-size: 10px;
            color: rgba(255,255,255,.25);
          }
        </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, config = {childList: true, attributes: false, characterData: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, config);
    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];
    });
  };
  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.time) console.timeEnd(SCRIPTNAME);
})();