Greasy Fork

Greasy Fork is available in English.

AbemaTV Screen Comment Scroller

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

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

您需要先安装一款用户脚本管理器扩展,例如 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     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);
})();