Greasy Fork is available in English.

AbemaTV Screen Comment Scroller

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

目前为 2017-08-12 提交的版本。查看 最新版本

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

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ScreenCommentScroller';
  const DEBUG = false;
  // delete localStorage['ScreenCommentScroller-configs'];
  if(window === top) console.time(SCRIPTNAME);
  const CONFIGS = [
    /*スクロールコメント*/
    {KEY: 'color',       DEFAULT: '#ffffff', TYPE: 'string'},/*色*/
    {KEY: 'ocolor',      DEFAULT: '#000000', TYPE: 'string'},/*縁取り色*/
    {KEY: 'owidth',      DEFAULT: 0.05,      TYPE: 'float' },/*縁取りの太さ(比率)*/
    {KEY: 'maxlines',    DEFAULT: 10,        TYPE: 'int'   },/*最大行数*/
    {KEY: 'linemargin',  DEFAULT: 0.2,       TYPE: 'float' },/*行間(比率)*/
    {KEY: 'opacity',     DEFAULT: 0.50,      TYPE: 'float' },/*不透明度*/
    {KEY: 'hopacity',    DEFAULT: 0.50,      TYPE: 'float' },/*不透明度(マウスオーバー時)*/
    /*一覧コメント*/
    {KEY: 'lt_opacity',  DEFAULT: 0.75,      TYPE: 'float' },/*文字の不透明度*/
    {KEY: 'lt_hopacity', DEFAULT: 1.00,      TYPE: 'float' },/*文字の不透明度(マウスオーバー時)*/
    {KEY: 'lb_opacity',  DEFAULT: 0.25,      TYPE: 'float' },/*背景の不透明度*/
    {KEY: 'lb_hopacity', DEFAULT: 0.50,      TYPE: 'float' },/*背景の不透明度(マウスオーバー時)*/
    /*アニメーション*/
    {KEY: 'duration',    DEFAULT: 5,         TYPE: 'float' },/*横断にかける秒数*/
    {KEY: 'fps',         DEFAULT: 60,        TYPE: 'int'   },/*秒間コマ数*/
  ];
  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) ? node.querySelectorAll('div[class^="uo_k"] p[class^="xH_fy"]') : null},
    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},
    getFullscreenButton: function(){return document.querySelector('button[aria-label="フルスクリーン表示"]')},
  };
  /* 処理本体 */
  let screen, board, video, canvas, context, lines = [], fontsize, interval, configButton, configPanel, configs = {}, style;
  let core = {
    /* 初期化 */
    initialize: function(){
      let currentUrl = location.href;
      window.addEventListener('load', core.ready);
      window.addEventListener('resize', setTimeout.bind(null, core.modify, 1000));
      setInterval(function(){
        if(location.href === currentUrl) return;
        if(!location.href.startsWith('https://abema.tv/now-on-air/')) return;
        core.ready();
        currentUrl = location.href;
      }, 1000);
      core.config.read();
      core.addStyle();
    },
    /* 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);
      /* 設定画面を用意する */
      core.config.createButton();
      /* コメントをスクロールさせる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');
      core.modify();
    },
    /* スクリーンサイズに変化があればcanvasも変化させる */
    modify: function(){
      canvas.width = screen.offsetWidth;
      canvas.height = screen.offsetHeight;
      fontsize = (canvas.height / configs.maxlines) / (1 + configs.linemargin);
      context.font = 'bold ' + (fontsize) + 'px sans-serif';
      context.fillStyle = configs.color;
      context.strokeStyle = configs.ocolor;
      context.lineWidth = fontsize * configs.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;
        /*投稿経過時間に合わせた時間差を付けることで自然に流す*/
        let earliest = ADELAYS[comments[comments.length - 1].nextElementSibling.textContent] || AINTERVAL;/*同時取得の中で最初に投稿されたコメントの経過時間*/
        for(let i = 0; comments[i]; i++){
          let current = ADELAYS[comments[i].nextElementSibling.textContent];
          if(current === undefined) current = 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.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):
            break;/*条件に当てはまればswitch文を抜けて行に追加*/
          default:
            continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/
        }
        record.top = ((canvas.height / configs.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描画 */
        let now = Date.now();
        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].left = canvas.width - ((now - lines[i][j].start) * lines[i][j].ppms);
          }
          if(lines[i][0] && lines[i][0].end < now) lines[i].shift();
        }
      }, 1000 / configs.fps);
    },
    /* 設定 */
    config: {
      read: function(){
        /* 保存済みの設定を読む */
        let ls = localStorage[SCRIPTNAME + '-configs'];
        if(ls) configs = JSON.parse(ls);
        /* 未定義項目をデフォルト値で上書きしていく */
        for(let i = 0; CONFIGS[i]; i++) if(configs[CONFIGS[i].KEY] === undefined) configs[CONFIGS[i].KEY] = CONFIGS[i].DEFAULT;
      },
      save: function(new_config){
        /* CONFIGSを元に文字列を型評価して値を格納していく */
        for(let i = 0; CONFIGS[i]; i++){
          /* 値がなければデフォルト値 */
          if(new_config[CONFIGS[i].KEY] === ""){
            configs[CONFIGS[i].KEY] = CONFIGS[i].DEFAULT;
            continue;
          }
          switch(CONFIGS[i].TYPE){
            case 'int':
              configs[CONFIGS[i].KEY] = parseInt(new_config[CONFIGS[i].KEY]);
              break;
            case 'float':
              configs[CONFIGS[i].KEY] = parseFloat(new_config[CONFIGS[i].KEY]);
              break;
            case 'string':
            default:
              configs[CONFIGS[i].KEY] = new_config[CONFIGS[i].KEY];
              break;
          }
        }
        localStorage[SCRIPTNAME + '-configs'] = JSON.stringify(configs);
      },
      createButton: function(){
        if(configButton) return;
        /* フルスクリーンボタンを元に設定ボタンを追加する */
        let fullscreen = site.getFullscreenButton();
        configButton = document.createElement('button');
        configButton.className = fullscreen.className;
        configButton.classList.add('hidden');
        configButton.id = SCRIPTNAME + '-config-button';
        configButton.innerHTML = core.config.buttonHtml();/*歯車*/
        configButton.setAttribute('title', SCRIPTNAME + '設定');
        configButton.addEventListener('click', core.config.togglePanel, true);
        fullscreen.parentNode.insertBefore(configButton, fullscreen);
        animate(function(){configButton.classList.remove('hidden')});
      },
      togglePanel: function(){
        if(configPanel) return core.config.closePanel();
        configPanel = document.createElement('div');
        configPanel.id = SCRIPTNAME + '-config-panel';
        configPanel.classList.add('hidden');
        configPanel.innerHTML = core.config.panelHtml();
        configPanel.querySelector('button.cancel').addEventListener('click', core.config.closePanel, true);
        configPanel.querySelector('button.save').addEventListener('click', function(){
          let inputs = configPanel.querySelectorAll('input'), new_configs = {};
          for(let i = 0; inputs[i]; i++) new_configs[inputs[i].name] = inputs[i].value;
          core.config.save(new_configs);
          /* 新しい設定値で再スタイリング */
          core.modify();
          core.addStyle();
          core.scrollComments();
          core.config.closePanel();
        }, true);
        document.body.appendChild(configPanel);
        animate(function(){configPanel.classList.remove('hidden')});
      },
      closePanel: function(){
        configPanel.classList.add('hidden');
        configPanel.addEventListener('transitionend', function(){
          document.body.removeChild(configPanel);
          configPanel = null;
        }, {once: true});
      },
      buttonHtml: function(){
        /* https://www.onlinewebfonts.com/icon/347 */
        return innerHTML = `<!-- iCon by oNlineWebFonts.Com --> <img src="data:image/svg+xml;base64,CjxzdmcgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMDAgMTAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMTAwMCAxMDAwIiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPG1ldGFkYXRhPiBTdmcgVmVjdG9yIEljb25zIDogaHR0cDovL3d3dy5vbmxpbmV3ZWJmb250cy5jb20vaWNvbiA8L21ldGFkYXRhPgogIDxnPjxnPjxwYXRoIGQ9Ik00OTkuOSwzMjIuOGMtOTcuOCwwLTE3Ny4xLDc5LjMtMTc3LjEsMTc3LjJjMCw5Ny44LDc5LjMsMTc3LjMsMTc3LjEsMTc3LjNjOTcuOCwwLDE3Ni42LTc5LjUsMTc2LjYtMTc3LjNDNjc2LjUsNDAyLjEsNTk3LjgsMzIyLjgsNDk5LjksMzIyLjh6IE04NTUuMSw2MDEuOGwtMzEuOSw3Ni45bDY0LjUsMTI2LjZsLTc5LDc5bC0xMjkuNS02MS4ybC03Ni45LDMxLjZsLTM5LDExOS41bC01LDE1LjlINDQ2LjZsLTQ4LjMtMTM0LjlsLTc2LjktMzEuN2wtMTI2LjgsNjQuMmwtNzguOS03OC45bDYxLjEtMTI5LjZsLTMxLjctNzYuOEwxMCw1NTguMlY0NDYuNmwxMzUtNDguNGwzMS43LTc2LjhsLTU2LjgtMTEyLjFsLTcuNS0xNC43bDc4LjgtNzguOEwzMjAuOSwxNzdsNzYuOC0zMS44bDM5LTExOS40bDUtMTUuOGgxMTEuNmw0OC4zLDEzNWw3Ni43LDMxLjhsMTI2LjktNjQuM2w3OC45LDc4LjhsLTYxLjEsMTI5LjVsMzEuNiw3Ni45bDEzNS40LDQ0djExMS41TDg1NS4xLDYwMS44TDg1NS4xLDYwMS44eiIgc3R5bGU9ImZpbGw6I0ZGRkZGRiI+PC9wYXRoPjwvZz48L2c+PC9zdmc+CiAg" width="22" height="22">`;
      },
      panelHtml: function(){
        return innerHTML = `
          <h1>${SCRIPTNAME}設定</h1>
          <fieldset>
            <legend>スクロールコメント</legend>
            <p><label>色:                         <input type="color"  name="color"      value="${configs.color}"></label></p>
            <p><label>縁取り色:                   <input type="color"  name="ocolor"     value="${configs.ocolor}"></label></p>
            <p><label>縁取りの太さ(比率):         <input type="number" name="owidth"     value="${configs.owidth}"     min="0" max="0.2" step="0.01"></label></p>
            <p><label>最大行数:                   <input type="number" name="maxlines"   value="${configs.maxlines}"   min="1" max="25"  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="opacity"    value="${configs.opacity}"    min="0" max="1"   step="0.05"></label></p>
            <p><label>不透明度(マウスオーバー時): <input type="number" name="hopacity"   value="${configs.hopacity}"   min="0" max="1"   step="0.05"></label></p>
          </fieldset>
          <fieldset>
            <legend>一覧コメント</legend>
            <p><label>文字の不透明度:                   <input type="number" name="lt_opacity"  value="${configs.lt_opacity}"  min="0" max="1" step="0.05"></label></p>
            <p><label>文字の不透明度(マウスオーバー時): <input type="number" name="lt_hopacity" value="${configs.lt_hopacity}" min="0" max="1" step="0.05"></label></p>
            <p><label>背景の不透明度:                   <input type="number" name="lb_opacity"  value="${configs.lb_opacity}"  min="0" max="1" step="0.05"></label></p>
            <p><label>背景の不透明度(マウスオーバー時): <input type="number" name="lb_hopacity" value="${configs.lb_hopacity}" min="0" max="1" step="0.05"></label></p>
          </fieldset>
          <fieldset>
            <legend>アニメーション</legend>
            <p><label>横断にかける秒数: <input type="number" name="duration" value="${configs.duration}" min="1" max="10"  step="1"></label></p>
            <p><label>秒間コマ数:       <input type="number" name="fps"      value="${configs.fps}"      min="1" max="240" step="1"></label></p>
          </fieldset>
          <p class="buttons"><button class="cancel">キャンセル</button><button class="save">保存</button></p>
          <p class="license">Icon made from <a href="http://www.onlinewebfonts.com/icon">Icon Fonts</a> is licensed by CC BY 3.0</p>
        `;
      },
    },
    addStyle: function(){
      if(style) document.head.removeChild(style);
      (function(css){
        style = document.createElement('style');
        style.type = 'text/css';
        style.textContent = css.replace(/^<style>([^]*)<\/style>$/, '$1');
        document.head.appendChild(style);
      })(innerHTML = `<style>
        /* スクロールコメント */
        canvas#${SCRIPTNAME}{
          pointer-events: none;
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          opacity: ${configs.opacity};
          transition: 500ms ease 0ms;
        }
        body:hover canvas#${SCRIPTNAME}{
          opacity: ${configs.hopacity};
        }
        /* コメントを表示させても映像を画面いっぱいに */
        div[class^="v3_ws"],
        div[class^="v3_ws"] > div{
          width: 100% !important;
          height: 100% !important;
        }
        /* 右コメント一覧を透明に */
        div[class^="v3_wi"]{
          mix-blend-mode: hard-light;/*https://stackoverflow.com/questions/15597167/css3-opacity-gradient*/
          background: rgba(0,0,0,${configs.lb_opacity});
          transition: 500ms ease 0ms;
          z-index: 9;/*右側に表示される番組情報や右下のコントローラより下層に*/
        }
        div[class^="v3_wi"]:hover{
          background: rgba(0,0,0,${configs.lb_hopacity});
        }
        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,${configs.lt_opacity});
        }
        div[class^="v3_wi"]:hover *{
          color: rgba(255,255,255,${configs.lt_hopacity});
        }
        /* 右コメント一覧のスクロールバーを美しく */
        div[class^="v3_wi"] > div > div{
          overflow-y: hidden;
        }
        div[class^="v3_wi"]:hover > div > div{
          overflow-y: auto;
        }
        div[class^="v3_wi"] > div > div::-webkit-scrollbar{
          background: rgba(255,255,255,0);
        }
        div[class^="v3_wi"] > div > div::-webkit-scrollbar-thumb{
          background: rgba(255,255,255,${configs.lt_hopacity/2});
        }
        /* マウスオーバー時だけナビゲーションを表示させる */
        body div[class^="v3_v_"]{
          transform: translateY(200%);
        }
        body:hover div[class^="v3_v_"]{
          transform: translateY(0%);
          visibility: visible;
        }
        /* 設定 */
        #${SCRIPTNAME}-config-button{
          right: 125px;
          transition: 500ms ease 0ms;
        }
        #${SCRIPTNAME}-config-button.hidden,
        div[aria-hidden="false"] #${SCRIPTNAME}-config-button/*コメント非表示の時*/{
          bottom: -22px;
        }
        #${SCRIPTNAME}-config-panel{
          position: fixed;
          width: 360px;
          left: 50%;
          bottom: 50%;
          transform: translate(-50%, 50%);
          z-index: 100;
          background: rgba(0,0,0,.75);
          transition: 500ms ease 0ms;
          padding: 5px 0;
        }
        #${SCRIPTNAME}-config-panel.hidden{
          bottom: -600px;
        }
        #${SCRIPTNAME}-config-panel h1,
        #${SCRIPTNAME}-config-panel legend,
        #${SCRIPTNAME}-config-panel p{
          color: rgba(255,255,255,1);
          font-size: 14px;
          padding: 5px 10px;
          line-height:1.25;
        }
        #${SCRIPTNAME}-config-panel fieldset p{
          padding-left: 30px;
        }
        #${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.buttons{
          text-align: right;
        }
        #${SCRIPTNAME}-config-panel 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}-config-panel button.save{
          font-weight: bold;
          background: rgba(0,0,0,1);
        }
        #${SCRIPTNAME}-config-panel button:hover{
          background: rgba(128,128,128,1);
        }
        #${SCRIPTNAME}-config-panel p.license,
        #${SCRIPTNAME}-config-panel p.license a{
          font-size: 10px;
          color: rgba(255,255,255,.25);
        }
      </style>`);
    },
  };
  let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  let innerHTML = '';/*trick for syntax highlighting, waiting js engines support html template*/
  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);
})();