AbemaTV Screen Comment Scroller

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

(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 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^="styles__comment-list-wrapper___"]')},
    getComments: function(node){return node.querySelectorAll('div[class^="styles__animation___"] p[class^="styles__message___"]')},
    getVideo:    function(){return document.querySelector('video[src]')},
    isPlaying:   function(video){return !video.paused},
  };
  /* 処理本体 */
  let screen, board, play, 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){
      /* 主要要素が取得できるまで読み込み待ち */
      screen = site.getScreen();
      board = site.getBoard();
      play = site.getVideo();
      if(!screen || !board || !play) 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) return;
      //console.log(SCRIPTNAME, 'modify...');
      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:
            /*条件に当てはまらなければ次の行に入れられるかの判定へ*/
            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(play)) 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]};
    }
    /* コメントを表示させても映像を画面いっぱいに */
    div[class^="styles__tv-container___"]{
      width: 100% !important;
      height: 100% !important;
    }
    div[class^="styles__tv-container___"] > div{
      width: 100% !important;
      height: 100% !important;
    }
    /* 右コメントエリアを透明に */
    div[class^="styles__right-comment-area___"]{
      background: linear-gradient(to bottom, rgba(0,0,0,${OPACITY[2]}) 50%, rgba(0,0,0,0));
      mix-blend-mode: hard-light;
    }
    div[class^="styles__right-comment-area___"]::after{
      pointer-events: none;
      position: absolute;
      content: "";
      left: 0px;
      top: 0px;
      height: 100%;
      width: 100%;
      background: linear-gradient(transparent 50%, gray);
    }
    div[class^="styles__right-comment-area___"] *{
      background: transparent;
      color: rgba(255,255,255,${OPACITY[1]});
    }
    /* 右側に番組情報を表示した際に透明化したコメントとかぶらないように */
    div[class^="styles__right-slide___"]{
      z-index: 11;
    }
    /* マウスホバー時だけナビゲーションを表示させる */
    body:hover div[class^="styles__footer-container___"]{
      transform: none !important;
    }
    body:hover div[class*="styles__footer-container--hide___"]{
      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);
})();