Twitch Screen Comment Scroller(fix)

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

// ==UserScript==
// @name        Twitch Screen Comment Scroller(fix)
// @namespace   page.loupe.tsscf
// @description Twitch のコメントをニコニコ風にスクロールさせます。
// @match       https://www.twitch.tv/*
// @version     0.5.2
// @require     https://openuserjs.org/src/libs/sizzle/GM_config.js
// @grant       GM_registerMenuCommand
// @grant       GM_getValue
// @grant       GM_setValue
// @author      knoa
// @author      mikan-megane
// ==/UserScript==

(function () {
  /* カスタマイズ(設定画面での変更が優先されます) */
  var SCRIPTNAME = 'ScreenCommentScroller';
  var COLOR = '#ffffff';/*コメント色*/
  var OCOLOR = '#000000';/*コメント縁取り色*/
  var OWIDTH = 1 / 10;/*コメント縁取りの太さ(比率)*/
  var OPACITY = '0.25';/*コメントの不透明度*/
  var MAXLINES = 10;/*コメント最大行数*/
  var LINEHEIGHT = 1.2;/*コメント行高さ*/
  var DURATION = 5;/*スクロール秒数*/
  var FPS = 60;/*秒間コマ数*/
  var EMOJI = true;/*コメントが絵文字だけの場合絵文字を表示するか*/
  var EMOJISIZE = 1.5;/*絵文字の大きさ*/
  var EMOJILIMIT = 10;/*絵文字の最大数*/
  var SHOW = '1';/*コメントを表示するか*/

  /* サイト定義 */
  var site = {
    getScreen: () => document.querySelector('.video-player__container'),
    getBoard: () => document.querySelector('.chat-scrollable-area__message-container') || document.querySelector('.video-chat__message-list-wrapper ul'),/*live || log*/
    getComments: (node) => node?.querySelector('[data-a-target="chat-line-message-body"],.video-chat__message'),
    getVideo: () => document.querySelector('video:not([id="' + SCRIPTNAME + 'Video"])'),
  };
  /* 処理本体 */
  var screen, board, video, canvas, context, lines = [], fontsize, scrollCommentsTimer, title = document.title, configEdit = false, commentObserver, canvasVideo;
  var core = {
    /* DOMの初期化待ち&ページ変更検知 */
    waitStart() {
      window.setInterval(function () {
        var screen_ = site.getScreen();
        var board_ = site.getBoard();
        var video_ = site.getVideo();
        var title_ = document.title;
        // console.debug('wait comment list...', { screen_, board_, video_ });
        if (screen_ && board_ && video_ && (screen_ != screen || board_ != board || video_ != video || title_ != title || configEdit || canvas.width != screen.offsetWidth) && !document.pictureInPictureElement) {
          screen = screen_;
          board = board_;
          video = video_;
          title = title_;
          configEdit = false;
          core.initialize();
        }
      }, 3000);
    },
    /* 初期化 */
    initialize() {
      /* コメントをスクロールさせるCanvasの設置 */
      /* (描画処理の軽さは HTML5 Canvas, CSS Position Left, CSS Transition の順) */
      canvas?.remove();
      canvas = document.createElement('canvas');
      canvas.id = SCRIPTNAME;
      screen.appendChild(canvas);
      context = canvas.getContext('2d');
      /* メイン処理 */
      core.addStyle();
      core.modify();
      core.initPip();
      core.listenComments();
      core.scrollComments();

      var controlsTimer = window.setInterval(() => {
        var controls = document.querySelector('.player-controls__right-control-group');
        if (!controls) return;

        clearInterval(controlsTimer);
        var settingButton = controls.querySelector('button[data-a-target="player-settings-button"]');

        var chatButton = controls.querySelector('#' + SCRIPTNAME + 'ChatButton');
        if (!chatButton) {
          chatButton = settingButton.closest('.player-controls__right-control-group > div').cloneNode(true);
          chatButton.id = SCRIPTNAME + 'ChatButton';
          var svgClass = chatButton.querySelector('svg').getAttribute('class');
          chatButton.querySelector('svg').outerHTML = '<svg fill="#000000" height="100%" width="100%" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 60 60" xml:space="preserve"><path d="M30,1.5c-16.542,0-30,12.112-30,27c0,5.205,1.647,10.246,4.768,14.604c-0.591,6.537-2.175,11.39-4.475,13.689	c-0.304,0.304-0.38,0.769-0.188,1.153C0.276,58.289,0.625,58.5,1,58.5c0.046,0,0.093-0.003,0.14-0.01	c0.405-0.057,9.813-1.412,16.617-5.338C21.622,54.711,25.738,55.5,30,55.5c16.542,0,30-12.112,30-27S46.542,1.5,30,1.5z"/></svg>';
          chatButton.querySelector('svg').setAttribute('class', svgClass);
          chatButton.querySelector('button').addEventListener('click', () => {
            SHOW = (SHOW == '1') ? '0' : '1';
            chatButton.style.opacity = (SHOW == '1') ? 1 : 0.5;
            GM_config.set('SHOW', SHOW);
            GM_config.write();
            lines = [];
            configEdit = true;
          });
          controls.insertBefore(chatButton, settingButton.closest('.player-controls__right-control-group > div'));
        }
        chatButton.style.opacity = (SHOW == '1') ? 1 : 0.5;

        settingButton.removeEventListener('click', core.showVideoSettings);
        settingButton.addEventListener('click', core.showVideoSettings);
      }, 10);

    },
    /* *スクリーンサイズに変化があればcanvasも変化させる* */
    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;
    },
    /* スタイル付与 */
    addStyle() {
      let head = document.querySelector('head');
      if (!head) return;
      document.querySelector('style#' + SCRIPTNAME + 'Style')?.remove();
      let style = document.createElement('style');
      style.type = 'text/css';
      style.id = SCRIPTNAME + 'Style';
      style.innerHTML = '' +
        'canvas#' + SCRIPTNAME + '{' +
        ' pointer-events: none;' +
        ' position: absolute;' +
        ' top: 0;' +
        ' left: 0;' +
        ' width: 100%;' +
        ' height: 100%;' +
        ' opacity: ' + OPACITY + ';' +
        ' z-index: 99999;' +
        '}' +
        '';
      head.appendChild(style);
    },
    /* コメントの新規追加を見守るイベント */
    listenComments() {
      if (commentObserver) {
        commentObserver.disconnect()
      }
      commentObserver = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
          if (mutation.type == 'childList') {
            mutation.addedNodes.forEach(function (node) {
              core.attachComment(site.getComments(node));
            });
          }
        });
      })
      commentObserver.observe(board, { childList: true });
    },
    /* コメントが追加されるたびにスクロールキューに追加 */
    attachComment(comment) {
      if (!comment) return;
      let record = {};
      record.text = comment.querySelector('[data-a-target="chat-message-text"]')?.textContent?.trim();/*流れる文字列*/
      record.img = Array.from(comment.querySelectorAll('img'));/*絵文字*/
      record.img.splice(EMOJILIMIT);/*絵文字の最大数を制限*/
      record.width = record.text ? context.measureText(record.text).width : context.measureText('絵').width * EMOJISIZE * record.img.length;/*文字列の幅*/
      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() {
      if (scrollCommentsTimer) {
        window.clearInterval(scrollCommentsTimer);
      }
      scrollCommentsTimer = window.setInterval(function () {
        /* 再生中じゃなければ処理しない */
        if (video.paused) return;
        /* Canvas描画 */
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.globalAlpha = 1;
        if (document.pictureInPictureElement) {
          canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
          context.globalAlpha = OPACITY;
        }
        if (!(SHOW == '1')) return;
        for (let i = 0; lines[i]; i++) {
          for (let j = 0; lines[i][j]; j++) {
            if (lines[i][j].text) {
              /*視認性を向上させるスクロール文字の縁取りは、幸いにもパフォーマンスにほぼ影響しない*/
              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);
            } else if (lines[i][j].img && EMOJI && !document.pictureInPictureElement) {
              /* オリジンの都合上、絵文字はPIPに描画できない */
              lines[i][j].img.forEach((_, k) => {
                /* k = 何文字目 */
                context.drawImage(
                  lines[i][j].img[k],
                  lines[i][j].left + (k * fontsize * EMOJISIZE), // 1文字分ずつ位置をずらす
                  lines[i][j].top,
                  fontsize * EMOJISIZE,
                  fontsize * EMOJISIZE
                );
              });
            }
            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);
    },
    /* 設定画面 */
    settings() {
      if (typeof GM !== 'undefined' && GM && GM.registerMenuCommand) {
        GM.registerMenuCommand("設定", () => GM_config.open());
      } else if (typeof GM_registerMenuCommand !== 'undefined') {
        GM_registerMenuCommand("設定", () => GM_config.open());
      }
      const config_init = () => {
        COLOR = GM_config.get('COLOR') || COLOR;
        OCOLOR = GM_config.get('OCOLOR') || OCOLOR;
        OWIDTH = GM_config.get('OWIDTH') || OWIDTH;
        OPACITY = GM_config.get('OPACITY') || OPACITY;
        MAXLINES = GM_config.get('MAXLINES') || MAXLINES;
        LINEHEIGHT = GM_config.get('LINEHEIGHT') || LINEHEIGHT;
        DURATION = GM_config.get('DURATION') || DURATION;
        FPS = GM_config.get('FPS') || FPS;
        EMOJI = GM_config.get('EMOJI') || EMOJI;
        EMOJISIZE = GM_config.get('EMOJISIZE') || EMOJISIZE;
        EMOJILIMIT = GM_config.get('EMOJILIMIT') || EMOJILIMIT;
        SHOW = GM_config.get('SHOW') || SHOW;
        configEdit = true;
      }
      GM_config.init({
        'id': 'ScreenCommentScroller',
        'title': 'ScreenCommentScroller',
        'fields': {
          'COLOR': {
            'label': 'コメント色',
            'type': 'text',
            'default': COLOR
          },
          'OCOLOR': {
            'label': 'コメント縁取り色',
            'type': 'text',
            'default': OCOLOR
          },
          'OWIDTH': {
            'label': 'コメント縁取りの太さ(比率)',
            'type': 'float',
            'default': OWIDTH
          },
          'OPACITY': {
            'label': 'コメントの不透明度',
            'type': 'float',
            'default': OPACITY
          },
          'MAXLINES': {
            'label': 'コメント最大行数',
            'type': 'int',
            'default': MAXLINES
          },
          'LINEHEIGHT': {
            'label': 'コメント行高さ',
            'type': 'float',
            'default': LINEHEIGHT
          },
          'DURATION': {
            'label': 'スクロール秒数',
            'type': 'float',
            'default': DURATION
          },
          'FPS': {
            'label': '秒間コマ数',
            'type': 'int',
            'default': FPS
          },
          'EMOJI': {
            'label': 'コメントが絵文字だけの場合絵文字を表示するか',
            'type': 'checkbox',
            'default': EMOJI
          },
          'EMOJISIZE': {
            'label': '絵文字の大きさ',
            'type': 'float',
            'default': EMOJISIZE
          },
          'EMOJILIMIT': {
            'label': '絵文字の最大表示数',
            'type': 'int',
            'default': EMOJILIMIT
          },
          'SHOW': {
            'label': 'コメントを表示するか',
            'type': 'int',
            'default': SHOW
          },
        },
        'events': {
          'init': () => config_init(),
          'save': () => config_init(),
        },
      });
    },
    showVideoSettings() {
      if (!document.pictureInPictureElement) {
        core.initialize();
      }
      if (document.querySelector('#' + SCRIPTNAME + 'SettingButton')) {
        return;
      }
      var menuTimer = window.setInterval(() => {
        var menus = document.querySelector('div[data-a-target="player-settings-menu"]')
        if (menus) {
          clearInterval(menuTimer);
          if(menus.querySelector('[data-from="' + SCRIPTNAME + '"]')){
            return;
          }
          var closeButton = menus.querySelector('[role="menuitem"] button');
          var separator = menus.querySelector('[role="separator"]').cloneNode(true);
          separator.setAttribute('data-from', SCRIPTNAME);
          var settingButton = menus.querySelector('[role="menuitem"]:last-of-type').cloneNode(true);
          settingButton.id = SCRIPTNAME + 'SettingButton';
          settingButton.setAttribute('data-from', SCRIPTNAME);
          settingButton.querySelector('button div').textContent = 'コメントスクロール設定';
          settingButton.querySelector('button').addEventListener('click', (e) => {
            closeButton.click();
            GM_config.open()
          });

          var pipButton = menus.querySelector('[role="menuitem"]:last-of-type').cloneNode(true);
          pipButton.setAttribute('data-from', SCRIPTNAME);
          pipButton.querySelector('button div').textContent = 'コメント付きPIP';
          pipButton.querySelector('button').addEventListener('click', () => {
            closeButton.click();
            core.startPip();
          });

          menus.appendChild(separator);
          menus.appendChild(settingButton);
          menus.appendChild(pipButton);
          menus.addEventListener('click', () => {
            menus.querySelectorAll('[data-from="' + SCRIPTNAME + '"]').forEach((menu) => menu.remove());
          })
        }
      }, 10);
    },
    initPip() {
      canvasVideo?.remove();
      canvasVideo = document.createElement('video');
      canvasVideo.id = SCRIPTNAME + 'Video';
      canvasVideo.muted = true;
      canvasVideo.style.top = 0;
      canvasVideo.style.opacity = 0;
      canvasVideo.style.pointerEvents = 'none';
      canvasVideo.addEventListener('enterpictureinpicture', () => {
        canvasVideo.play();
        canvas.style.visibility = 'hidden';
        video.style.opacity = 0.3;

        var message = document.createElement('div');
        message.id = SCRIPTNAME + 'Message';
        message.style.position = 'absolute';
        message.style.fontSize = '1.5em';
        message.style.color = 'white';
        message.textContent = 'ピクチャー イン ピクチャーで再生しています';
        video.parentNode.insertBefore(message, video);
      });
      canvasVideo.addEventListener('leavepictureinpicture', () => {
        canvasVideo.pause();
        canvas.style.visibility = 'visible';
        video.style.opacity = 1;
        document.querySelector('#' + SCRIPTNAME + 'Message')?.remove();
      });
      canvas.parentNode.insertBefore(canvasVideo, canvas);
      canvasVideo.srcObject = canvas.captureStream(FPS);
    },
    startPip() {
      if (!document.pictureInPictureEnabled) {
        alert('このブラウザではピクチャーインピクチャーが利用できません');
        return;
      }
      if (document.pictureInPictureElement) {
        document.exitPictureInPicture();
      } else {
        canvasVideo.requestPictureInPicture();
      }
    }
  };
  core.settings();
  core.waitStart();
})();