Greasy Fork is available in English.

AbemaTV Screen Comment Scroller

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

As of 2017-08-10. See the latest version.

// ==UserScript==
// @name        AbemaTV Screen Comment Scroller
// @namespace   knoa.jp
// @description AbemaTV のコメントをニコニコ風にスクロールさせます。
// @description Firefoxでたまにコメントが流れなくなるバグあり。視聴ページを再読込すれば復活します。
// @include     https://abema.tv/*
// @version     1.2.1
// @grant       none
// ==/UserScript==

// console.log('AbemaTV? => hireMe()');
(function(){
  /* カスタマイズ */
  const SCRIPTNAME = 'ScreenCommentScroller';
  const DEBUG = false;
  if(window === top) console.time(SCRIPTNAME);
  const COLOR = '#ffffff';/*スクロールコメント色*/
  const OCOLOR = '#000000';/*スクロールコメント縁取り色*/
  const OWIDTH = 1/20;/*スクロールコメント縁取りの太さ(比率)*/
  const OPACITY  = ['.5', '.75', '.25'];/*スクロールコメント,一覧コメント文字,一覧コメント背景の不透明度*/
  const HOPACITY = ['.5', '1.0', '.50'];/*同マウスオーバー時の不透明度*/
  const MAXLINES = 10;/*スクロールコメント最大行数*/
  const LINEHEIGHT = 1.2;/*スクロールコメント行高さ*/
  const DURATION = 5;/*スクロール秒数*/
  const FPS = 60;/*秒間コマ数*/
  const AINTERVAL = 5;/*AbemaTVのコメント取得間隔の仕様値*/
  const ADELAYS = {/*AbemaTVのコメント取得時の投稿時刻を(AINTERVAL)まで用意しておく*/
    '今': 0,
    '1秒前': 1,
    '2秒前': 2,
    '3秒前': 3,
    '4秒前': 4,
    '5秒前': 5,
  };
  /* サイト定義 */
  let site = {
    getScreen:   function(){return document.querySelector('main')},
    getBoard:    function(){return document.querySelector('div[class^="v3_wi"]')},
    getComments: function(node){return node.querySelectorAll('div[class^="uo_k"] p[class^="xH_fy"]')},
    getVideo:    function(){return true},
    isPlaying:   function(video){return true},
    getCommentButton: function(){let svg = document.querySelector('use[*|href="/images/icons/comment.svg#svg-body"]'); return (svg) ? svg.parentNode.parentNode : null},
  };
  /* 処理本体 */
  let screen, board, video, canvas, context, lines = [], fontsize, interval;
  let core = {
    /* 初期化 */
    initialize: function(){
      let currentUrl = location.href;
      window.addEventListener('load', core.ready);
      setInterval(function(){
        if(location.href === currentUrl) return;
        if(!location.href.startsWith('https://abema.tv/now-on-air/')) return;
        core.ready();
        currentUrl = location.href;
      }, 1000);
    },
    /* URLが変わるたびに呼ぶ */
    ready: function(e){
      /* コメント表示可能になるのを待つ */
      let commentButton = site.getCommentButton();
      if(!commentButton || getComputedStyle(commentButton).cursor !== 'pointer') return setTimeout(core.ready, 1000);
      commentButton.click();
      /* 主要要素が取得できるのを待つ */
      screen = site.getScreen();
      board = site.getBoard();
      video = site.getVideo();
      if(!screen || !board || !video) return setTimeout(core.ready, 1000);
      /* コメントをスクロールさせるCanvasの設置 */
      /* (描画処理の軽さは HTML5 Canvas, CSS Position Left, CSS Transition の順) */
      core.createCanvas();
      /* メイン処理 */
      core.listenComments();
      core.scrollComments();
    },
    /* canvas作成 */
    createCanvas: function(){
      if(canvas) return;
      canvas = document.createElement('canvas');
      canvas.id = SCRIPTNAME;
      screen.appendChild(canvas);
      context = canvas.getContext('2d');
    },
    /* スクリーンサイズに変化があればcanvasも変化させる */
    modify: function(){
      if(canvas.width === screen.offsetWidth && canvas.height === screen.offsetHeight) return;
      canvas.width = screen.offsetWidth;
      canvas.height = screen.offsetHeight;
      fontsize = (canvas.height / MAXLINES) / LINEHEIGHT;
      context.font = 'bold ' + (fontsize) + 'px sans-serif';
      context.fillStyle = COLOR;
      context.strokeStyle = OCOLOR;
      context.lineWidth = fontsize * OWIDTH;
    },
    /* コメントの新規追加を見守る */
    listenComments: function(){
      if(board.isListening) return;
      board.isListening = true;
      board.addEventListener('DOMNodeInserted', function(e){
        let comments = site.getComments(e.target);
        if(!comments || !comments.length) return;
        core.modify();
        /*投稿経過時間に合わせた時間差を付けることで自然に流す*/
        let earliest = ADELAYS[comments[comments.length - 1].nextElementSibling.textContent] || AINTERVAL;/*同時取得の中で最初に投稿されたコメントの経過時間*/
        for(let i=0; comments[i]; i++){
          let current = ADELAYS[comments[i].nextElementSibling.textContent] || AINTERVAL;
          window.setTimeout(function(){
            core.attachComment(comments[i]);
          }, 1000 * (earliest  - current));
        }
      });
    },
    /* コメントが追加されるたびにスクロールキューに追加 */
    attachComment: function(comment){
      let record = {};
      record.text = comment.textContent;/*流れる文字列*/
      record.width = context.measureText(record.text).width;/*文字列の幅*/
      record.life = DURATION * FPS;/*文字列が消えるまでのコマ数*/
      record.left = canvas.width;/*左端からの距離*/
      record.delta = (canvas.width + record.width) / (record.life);/*コマあたり移動距離*/
      record.reveal = record.width / record.delta;/*文字列が右端から抜けてあらわになるまでのコマ数*/
      record.touch = canvas.width / record.delta;/*文字列が左端に触れるまでのコマ数*/
      /* 追加されたコメントをどの行に流すかを決定する */
      for(let i=0; i<MAXLINES; i++){
        let length = lines[i] ? lines[i].length : 0;/*同じ行に詰め込まれているコメント数*/
        switch(true){
          /* 行が空いていれば追加 */
          case(lines[i] === undefined || !length):
            lines[i] = [];
          /* 以前のコメントより長い(速い)文字列なら、左端に到達する時間で判断する */
          case(lines[i][length - 1].reveal < 0 && lines[i][length - 1].delta > record.delta):
          /* 以前のコメントより短い(遅い)文字列なら、右端から姿を見せる時間で判断する */
          case(lines[i][length - 1].life < record.touch && lines[i][length - 1].delta < record.delta):
            /*条件に当てはまればすべてswitch文を抜けて行に追加*/
            break;
          default:
            /*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/
            continue;
        }
        record.top = ((canvas.height / MAXLINES) * i) + fontsize;
        lines[i].push(record);
        break;
      }
    },
    /* FPSタイマー駆動 */
    scrollComments: function(){
      if(interval) clearInterval(interval);
      interval = window.setInterval(function(){
        context.clearRect(0, 0, canvas.width, canvas.height);
        /* 再生中じゃなければ処理しない */
        if(!site.isPlaying(video)) return clearInterval(interval);
        /* Canvas描画 */
        for(let i=0; lines[i]; i++){
          for(let j=0; lines[i][j]; j++){
            /*視認性を向上させるスクロール文字の縁取りは、幸いにもパフォーマンスにほぼ影響しない*/
            context.strokeText(lines[i][j].text, lines[i][j].left, lines[i][j].top);
            context.fillText(lines[i][j].text, lines[i][j].left, lines[i][j].top);
            lines[i][j].life--;
            lines[i][j].reveal--;
            lines[i][j].touch--;
            lines[i][j].left -= lines[i][j].delta;
          }
          if(lines[i][0] && lines[i][0].life === 0){
            lines[i].shift();
          }
        }
      }, 1000/FPS);
    },
  };
  (function(css){
    let style = document.createElement('style');
    style.type = 'text/css';
    style.textContent = css;
    document.head.appendChild(style);
  })(`
    canvas#${SCRIPTNAME}{
      pointer-events: none;
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      opacity: ${OPACITY[0]};
      transition: 500ms ease 0ms;
    }
    canvas#${SCRIPTNAME}:hover{
      opacity: ${HOPACITY[0]};
    }
    /* コメントを表示させても映像を画面いっぱいに */
    div[class^="v3_ws"],
    div[class^="v3_ws"] > div{
      width: 100% !important;
      height: 100% !important;
    }
    /* 右コメントエリアを透明に */
    div[class^="v3_wi"]{
      mix-blend-mode: hard-light;
      background: rgba(0,0,0,${OPACITY[2]});
      transition: 500ms ease 0ms;
      z-index: 9;/* 右側に表示される番組情報や右下のコントローラより下層に */
    }
    div[class^="v3_wi"]:hover{
      background: rgba(0,0,0,${HOPACITY[2]});
    }
    div[class^="v3_wi"]::after{
      pointer-events: none;
      position: absolute;
      content: "";
      left: 0px;
      top: 0px;
      height: 100%;
      width: 100%;
      background: linear-gradient(transparent 50%, gray);
    }
    div[class^="v3_wi"] *{
      background: transparent;
      color: rgba(255,255,255,${OPACITY[1]});
      transition: 500ms ease 0ms;
    }
    div[class^="v3_wi"]:hover *{
      color: rgba(255,255,255,${HOPACITY[1]});
    }
    /* マウスオーバー時だけナビゲーションを表示させる */
    body:hover div[class^="v3_v_"]{
      transform: none !important;
    }
    body:hover div[class^="v3_v_"]{
      visibility: visible !important;
    }
  `);
  let log = (DEBUG) ? function(){
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    console.log(
      SCRIPTNAME + ':',
      /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s      */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00          */ ':' + new Error().stack.match(/:[0-9]+:[0-9]+/g)[1].split(':')[1],/*LINE*/
      /* caller       */ log.caller ? log.caller.name : '',
      ...arguments
    );
    if(arguments.length === 1) return arguments[0];
  } : function(){};
  core.initialize();
  if(window === top) console.timeEnd(SCRIPTNAME);
})();