NicoNico Tsuu

ニコニコライフを快適に。

目前为 2019-05-10 提交的版本。查看 最新版本

// ==UserScript==
// @name        NicoNico Tsuu
// @namespace   knoa.jp
// @description ニコニコライフを快適に。
// @include     https://www.nicovideo.jp/watch/*
// @include     https://live2.nicovideo.jp/watch/*
// @version     0.3.0
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTNAME = 'NicoNicoTsuu';
  const DEBUG = true;/*
[update] 0.3.0
ウィンドウフォーカスと映像クリックによるコメント入力欄へのフォーカスをやめて、スペースキーによるフォーカスに一本化。
数字キーによる流れるコメントの透明度調整に対応。[1]透過しない...[0]完全に透過。マウスオーバー時はさらに1/4に透過。
生放送中に[←]で10秒戻る(20秒かけて追いつく)。空欄ならコメント欄でも機能します。その間、流れるコメントは非表示になります。

[bug]

[to do]
# 共通
  設定/Help
    コメント一覧幅
  当該ユーザーの発言一覧
  連投規制されそうなとき、投稿ボタンにびっくりマーク
  ログインボタン目立たないとか勝手にログアウトするとか
# 生放送
  経過時間に当時の時刻を付与(タイムシフト時)
    番組内容にタイムスケジュールがあればリンク化
  フルスクリーン時に運営コメントが出たらCanvasを隠さないようにリサイズ
    10行目のコメの位置を飛ばさないようにtransformで上下圧縮にする。
  全画面時にも視聴数とコメント数をチラ見できる仕組み
  タイムシフト視聴するだのなんだのUI
  リアルタイム視聴でだんだんリアルタイムから遅れていく現象
    video.currentTime を監視してplaybackRateの調整で追いつきたい。
    低遅延なら自動で追いつく?一瞬で追いつくので途中切れるが。
    TSの30秒移動でいちいちつっかえるのは改善できないかな?
# 動画
  100%レイアウト([自動]時のみでよい)
    右に[動画説明文/コメント一覧]しかないのか?
  倍速再生にアクセスしやすく
  先頭と次の動画のボタンいらない気が
  NiconicoMyTheater継承のコメント・ショートカットキーUX
  ヒートマップ
  映像中央の再生マークに白い影

[to research]
マウスオーバー時のたまにスクロール止まらない件
TSで1.5倍速とかDLが追いつかない
TSで[Shift+←→]10秒移動
NGスコアの可視化
生放送TSヒートマップ
横型番組表

(コメントJSONの例)
chat: {
  thread : 1649308673,
  vpos : 4256100,
  date : 1556109561,
  date_usec : 880193,
  mail : "184",
  content : "/hb ifseetno 686",
  premium : 3,
  user_id : "SaMpLe",
  anonymity : 1,
  yourpost : 1,
  locale : "ja-jp",
}
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const API = {
    LIVEMSG: 'wss://msg.live2.nicovideo.jp/',
  };
  const PREMIUM = {
    USER:       1,/*プレミアム会員*/
    USERAD:     2,/*広告*/
    OPERATOR:   3,/*運営・システム*/
    GUEST:      7,/*公式ゲストコメント(?)*/
    CHANNEL8:   8,/*チャンネル会員(?)*/
    CHANNEL9:   9,/*チャンネル会員(?)*/
    CHANNEL24: 24,/*未入会(?)*/
    CHANNEL25: 25,/*未入会(?)*/
  };
  const STROKEALPHA = '1.0';/*流れるコメントの縁取りのアルファ値(公式: 0.4)*/
  const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/
  const RETRY = 10;
  let site = {
    targets: {
      leoPlayer: () => $('[class*="_leo-player_"]'),/*出没するplayer-statusの親*/
      playerDisplayHeader: () => $('[class*="_player-display-header_"]'),/*運営コメント*/
      playerDisplayScreen: () => $('[class*="_player-display-screen_"]'),
      interactionLayerContent: () => $('[class*="_interaction-layer_"] > [data-content-visibility]'),/*アンケート*/
      commentCanvas: () => $('[class*="_comment-layer_"] canvas'),
      muteButton: () => $('[class*="_mute-button_"]'),
      fullscreenButton: () => $('[class*="_fullscreen-button_"]'),
      reloadButton: () => $('[class*="_reload-button_"]'),
      commentTextBox: () => $('[class*="_comment-text-box_"]'),
      commentsTable: () => $('[class*="_comment-panel_"] [class*="_table_"]'),
      embeddedData: () => $('#embedded-data'),
    },
    get: {
      video: () => $('[class*="_video-layer_"] video[src]'),
      playButton: () => $('[class*="_play-button_"]'),
      announcement: (playerDisplayHeader) => playerDisplayHeader.querySelector('[class*="_announcement-renderer_"]'),
      content: (comment) => comment.querySelector('[class*="_comment-text_"]'),
      time: (comment) => comment.querySelector('[class*="_comment-time_"]'),
      props: (embeddedData) => JSON.parse(embeddedData.dataset.props),
    },
    addedNodes: {
      comment: (node) => (node.dataset.commentType) ? node : null,
    },
    is: {
      live: () => site.get.playButton() ? false : true,
      timeshift: () => site.get.playButton() ? true : false,
    },
  };
  let html, elements = {}, storages = {}, timers = {}, props, chats = [], users = {}/*id検索用テーブル*/, configs = {};
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTNAME);
      core.listenWebSockets();
      core.ready();
      core.addStyle();
      core.panel.createPanels();
    },
    ready: function(){
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready.");
        core.getProps();
        core.listenUserActions();
        core.listenCanvas();
        core.listenEnquete();
        core.observeCommentTable();
        core.appendIndicator();
        elements.commentCanvas.dataset.opacity = Storage.read('opacity') || '1';
      });
    },
    getProps: function(){
      props = site.get.props(elements.embeddedData);
      log(props);
    },
    appendIndicator: function(e){
      elements.indicator = createElement(core.html.indicator());
      elements.playerDisplayScreen.appendChild(elements.indicator);
    },
    indicate: function(node, duration = 1000){
      let indicator = elements.indicator;
      while(indicator.firstChild) indicator.removeChild(indicator.firstChild);
      indicator.appendChild(node);
      indicator.classList.add('active');
      clearTimeout(timers.indicator);
      timers.indicator = setTimeout(function(){
        indicator.classList.remove('active');
      }, duration);
    },
    listenUserActions: function(){
      /* キーボード */
      window.addEventListener('keydown', function(e){
        let activeElement = document.activeElement;
        /* テキスト入力中は反応しない */
        if(['input', 'textarea'].includes(activeElement.localName) && activeElement.name !== 'volumeSize'){
          if(e.key === 'Escape'){/*Escapeは必ずアンフォーカス*/
            activeElement.blur();
            e.stopPropagation();
            return;
          }
          if(document.activeElement.value !== '') return;/*テキスト入力中*/
          else if([/*テキスト空欄なら以下のキーは有効*/
            'ArrowLeft',
          ].includes(e.key) === false) return;
        }
        switch(true){
          //case(e.key === 'ArrowLeft'  && !e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey):
          //case(e.key === 'ArrowRight' && !e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey):
          //  if(site.is.live()) return;
          //  else{
          //    let video = site.get.video();
          //    video.currentTime += (e.key === 'ArrowLeft') ? -10 : +10;
          //    e.preventDefault();
          //  }
          //  return;
          /* 以下Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
          case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
            return;
          case(e.key === ' '):
            if(site.is.live()){
              elements.commentTextBox.focus();
              e.preventDefault();/*コメント欄にフォーカスさせるだけ*/
            }else{
              elements.playerDisplayScreen.click();
            }
            return;
          case(e.key === 'ArrowLeft'):
            if(site.is.timeshift()) elements.playerDisplayScreen.click();/*プレイヤーにフォーカスさせて公式の30秒巻き戻しを実行させる*/
            else{
              const REWIND = 10, CATCHUP = 1.5;
              let video = site.get.video(), rewinded = false, duration = 1000;
              if(!video.paused && !video.rewinded && video.currentTime > REWIND/*少しだけ戻すこともできるが通信が安定しなくなるので*/){
                duration = (REWIND / (CATCHUP - 1))*1000;
                video.rewinded = rewinded = true;
                video.currentTime = video.currentTime - REWIND;
                video.playbackRate = CATCHUP;
                elements.playerDisplayScreen.dataset.rewinded = 'true';
                setTimeout(function(){
                  video.rewinded = false;
                  video.playbackRate = 1;
                  delete elements.playerDisplayScreen.dataset.rewinded;
                }, duration);
              }
              core.indicate(createElement(core.html.rewind(rewinded)), duration);
            }
            return;
          case(e.key === 'ArrowRight'):
            if(site.is.timeshift()) elements.playerDisplayScreen.click();/*プレイヤーにフォーカスさせて公式の30秒巻き戻しを実行させる*/
            return;
          case(e.key === 'ArrowUp'):
          case(e.key === 'ArrowDown'):
            elements.playerDisplayScreen.click();/*プレイヤーにフォーカスさせて公式の音量調整を実行させる*/
            site.get.video().addEventListener('volumechange', function(e){
              core.indicate(document.createTextNode(e.target.volume * 100));
            }, {once: true});
            return;
          case(e.key === '1'):
          case(e.key === '2'):
          case(e.key === '3'):
          case(e.key === '4'):
          case(e.key === '5'):
          case(e.key === '6'):
          case(e.key === '7'):
          case(e.key === '8'):
          case(e.key === '9'):
          case(e.key === '0'):
            elements.commentCanvas.dataset.opacity = e.key;
            Storage.save('opacity', e.key);
            return;
          case(e.key === 'm'):
            elements.muteButton.click();
            return;
          case(e.key === 'f'):
            elements.fullscreenButton.click();
            return;
          case(e.key === 'r'):
            elements.reloadButton.click();
            return;
        }
      }, {capture: true});
      /* 出没するplayer-statusを監視 */
      observe(elements.leoPlayer, function(records){
        let commentsTable = elements.commentsTable = site.targets.commentsTable();
        if(commentsTable === null) return;
        commentsTable.dataset.selector = 'commentsTable';
        core.observeCommentTable();/*commentsTableが復活するのでもう一度監視する*/
      }, {childList: true});
      /* フルスクリーン状態の変化 */
      observe(html, function(records){
        if(html.dataset.browserFullscreen) return;/*フルスクリーン化したときは何もしない*/
        animate(window.scrollTo.bind(window, 0, 0));/*スクロール位置がずれるのを即補正*/
      }, {attributes: true});
      /* ウィンドウリサイズ */
      window.addEventListener('resize', function(e){
        /* ニコ生コメント一覧付き全画面シアターとの連携(なめらかスクロールをこちらで引き受ければ本来不要な処理のはず) */
        clearTimeout(window.resizing), window.resizing = setTimeout(function(){
          if(document.fullscreenElement) return;/*モニタフルスクリーン時は何もしない*/
          elements.fullscreenButton.click();
          elements.fullscreenButton.click();
          window.resizing = null;
        }, 250);/*リサイズ中の連続起動を避ける*/
      });
    },
    listenWebSockets: function(){
      /* 公式の通信内容を取得 */
      window.WebSocket = new Proxy(WebSocket, {
        construct(target, arguments){
          const ws = new target(...arguments);
          log(ws, arguments);
          if(ws.url.startsWith(API.LIVEMSG)) ws.addEventListener('message', function(e){
            let json = JSON.parse(e.data);
            //log(json);
            if(json.chat === undefined) return;
            if(![1,2,3,undefined].includes(json.chat.premium)) log(json.chat);/*ユーザーと広告と運営以外のコメントログ*/
            chats.push(json.chat);
            /* ユーザー別コメント一覧 */
            if(users[json.chat.user_id] === undefined) users[json.chat.user_id] = [];
            users[json.chat.user_id].push(json.chat);
          });
          return ws;
        }
      });
    },
    listenCanvas: function(){
      /* 公式のキャンバスコンテキストメソッドを書き換えて縁取りを見やすく */
      let strokeText = CanvasRenderingContext2D.prototype.strokeText;
      CanvasRenderingContext2D.prototype.strokeText = function(text, x, y, maxWidth){
        //log(text, this.strokeStyle);
        this.strokeStyle = this.strokeStyle.replace(/rgba\(([0-9]+),\s?([0-9]+),\s?([0-9]+),\s?([0-9.]+)\)/, `rgba($1,$2,$3,${STROKEALPHA})`);
        return strokeText.call(this, text, x, y, maxWidth);
      };
    },
    listenEnquete: function(){
      /* アンケートの表示を捉える */
      Notification.requestPermission();
      let notification, title = props.program.title;
      observe(elements.interactionLayerContent, function(records){
        if(notification) notification.close();/*古い通知が出たままなら閉じる*/
        if(elements.interactionLayerContent.dataset.contentVisibility === 'false') return;/*閉じたときは何もしない*/
        notification = new Notification(title, {body: site.get.announcement(elements.playerDisplayHeader).textContent});
        notification.addEventListener('click', function(e){
          notification.close();
        });
      }, {attributes: true});
    },
    observeCommentTable: function(){
      let commentsTable = elements.commentsTable, isTimeshift =  site.is.timeshift();
      if(commentsTable.observing) return;/*起こりえないけど重複を避ける*/
      commentsTable.observing = true;
      core.listenMouseOnCommentsTable();
      /* 初期コメントに適用しつつ、追加コメントを監視する */
      Array.from(commentsTable.children).forEach(c => core.modifyComment(c));
      observe(commentsTable, function(records){
        //log(records);
        let removedComments = [], newComments = [];
        records.forEach(r => {
          if(r.removedNodes.length) return removedComments.push(r.removedNodes[0]);
          if(r.addedNodes.length === 0) return;
          if(!site.addedNodes.comment(r.addedNodes[0])) return;
          core.modifyComment(r.addedNodes[0]);
          /* タイムシフトではユーザーコメント以外は毎回置換されるので(バグ?)、置換要素は新着コメント扱いしない */
          if(isTimeshift){
            if(!['normal', 'trialWatch'].includes(r.addedNodes[0].dataset.commentType)) return;
            if(removedComments.find(c => r.addedNodes[0].textContent === c.textContent)) return;
          }
          newComments.push(r.addedNodes[0]);
        });
        if(newComments.length) core.slideUpNewComments(newComments);
      });
    },
    listenMouseOnCommentsTable: function(){
      /* マウス操作中はスクロールでフォーカスが不意に外れてしまうのを抑制する */
      /* (マウスオーバーで新着スクロールを止めて、マウスアウトで一気に復帰させる) */
      let commentsTable = elements.commentsTable, parent = commentsTable.parentNode, scroll = 0;
      commentsTable.addEventListener('mouseenter', function(e){
        scroll = atMost(Math.round(parseFloat(getComputedStyle(commentsTable.lastElementChild).height)), parent.scrollTop);/*最初に少しスクロールさせると公式も空気を読んで新着コメントが来てもスクロールしなくなる*/
        commentsTable.dataset.mouseenter = 'true';
        parent.scrollTop -= scroll;
        commentsTable.style.transform = `translateY(-${scroll}px)`;
      });
      commentsTable.addEventListener('mouseleave', function(e){
        delete commentsTable.dataset.mouseenter;
        let scrollTopMax = parent.scrollHeight - parent.clientHeight, distance = scrollTopMax - parent.scrollTop - scroll;
        parent.scrollTop = scrollTopMax;
        animate(function(){parent.scrollTop = scrollTopMax + scroll});/*スクロールによって移動した分をさらに調整*/
        commentsTable.style.transform = ``;
        parent.animate([ 
          {transform: `translateY(${distance}px)`},
          {transform: `translateY(0)`},
        ], {duration: 125, easing: EASING});
        commentsTable.lastElementChild.dataset.new = 'true';/*すでに追加されている1件分*/
      });
    },
    modifyComment: function(commentNode){
      const additionalVpos = props.program.beginTime - props.program.openTime;
      const toVpos = function(time){
        let sign = (time[0] === '-') ? -1 : +1;
        let p = time.split(':').map(d => parseFloat(d)), s = 100, m = 60*s, h = 60*m;
        if(p[2]) return additionalVpos + sign * (sign*p[0]*h + p[1]*m + p[2]*s);
        if(p[1]) return additionalVpos + sign * (sign*p[0]*m + p[1]*s);
        if(p[0]) return additionalVpos + sign * (sign*p[0]*s);
      };
      let contentNode = site.get.content(commentNode), timeNode = site.get.time(commentNode);
      let commentType = commentNode.dataset.commentType, content = contentNode.textContent, vpos = toVpos(timeNode.textContent);
      /* コメントに追加情報を与える */
      for(let i = chats.length - 1, chat; chat = chats[i]; i--){
        /* 時刻の一致を検証 */
        if(!between(vpos, chat.vpos, vpos + 100)) continue;
        /* 既存の一致を検証 */
        if(chat.commentNode && chat.commentNode.isConnected) continue;
        /* 内容の一致を検証 */
        switch(commentType){
          case('normal'):
          case('trialWatch'):
            if(chat.content !== content) continue;
            break;
          case('operator'):
            let operator = content.split(/\s/);
            if(operator === null) log('Unknown nicoad format:', content);
            else if(!operator.every(o => chat.content.includes(o))) continue;
            break;
          case('nicoad'):
            let nicoad = content.match(/【.+?】(.+)さんが([0-9]+)pt/) || content.match(/提供:(.+)さん(([0-9]+)pt)/);
            if(nicoad === null) log('Unknown nicoad format:', content);
            else if(!chat.content.includes(nicoad[1]) || !chat.content.includes(nicoad[2])) continue;/*厳密ではないけど十分*/
            break;
          case('ranking'):
            if(content.includes(chat.content)) continue;
            break;
          default:
            break;
        }
        /* 晴れてペアとなるchatを見つけられたので */
        chats[i].commentNode = commentNode;
        switch(commentType){
          case('normal'):
          case('trialWatch'):
            linkify(contentNode);/*URLをリンク化*/
            commentNode.dataset.score = chat.score || 0;
            commentNode.dataset.premium = chat.premium || 0;
            commentNode.dataset.user_id = chat.user_id || '';
            timeNode.parentNode.insertBefore(createElement(core.html.score(commentNode.dataset.score)), timeNode);/*NGスコア付与*/
            //commentNode.addEventListener('click', core.showUserHistory.bind(commentNode), {capture: true});
            break;
          case('operator'):
            let link = chat.content.match(/<a href="([^"]+)"/);
            if(link === null) linkify(contentNode);/*URLをリンク化*/
            else contentNode.innerHTML = `<a href="${link[1]}">${content}</a>`;
            break;
          case('nicoad'):
            linkify(contentNode);/*URLをリンク化*/
            break;
          case('ranking'):
            break;
          default:
            log('Unknown commentType found:', commentType);
            break;
        }
        break;
      }
    },
    slideUpNewComments: function(newComments){
      if(elements.commentsTable.dataset.mouseenter) return;/*マウスオーバー中は処理しない*/
      //連続起動しうるけど125ms以内には起こらないだろう
      const DURATION = '125ms', EASING = 'ease';
      let commentsTable = elements.commentsTable, parent = commentsTable.parentNode;
      let scrollTopMax = parent.scrollHeight - parent.clientHeight;
      let height = parseFloat(getComputedStyle(newComments[0]).height) * newComments.length;/*高さは共通のはずなので*/
      for(let i = 0, comment; comment = newComments[i]; i++){
        comment.dataset.new = 'true';
      }
      if(scrollTopMax === 0) return;/*放送開始時などコメントが少なくてスクロール不要*/
      parent.scrollTop = scrollTopMax - 2;/* 本来は1でよいが、ブラウザのズーム倍率に対する保険 */
      commentsTable.style.transform = `translateY(${height - 2}px)`;
      animate(function(){
        commentsTable.style.transition = `transform ${DURATION} ${EASING}`;
        commentsTable.style.transform = `translateY(0)`;
        commentsTable.addEventListener('transitionend', function(e){
          commentsTable.style.transition = 'none';
          animate(function(){parent.scrollTop = scrollTopMax + 1});
        }, {once: true});
      });
    },
    showUserHistory: function(e){
      let commentNode = this, user_id = commentNode.dataset.user_id;
      log(this, user_id, users[user_id]);
    },
    getTargets: function(targets, retry = 0){
      const get = function(resolve, reject, retry){
        for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
          let selected = targets[key]();
          if(selected){
            if(selected.length) selected.forEach((s) => s.dataset.selector = key);
            else selected.dataset.selector = key;
            elements[key] = selected;
          }else{
            if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
            log(`Not found: ${key}, retrying... (left ${retry})`);
            return setTimeout(get, 1000, resolve, reject, retry);
          }
        }
        resolve();
      };
      return new Promise(function(resolve, reject){
        get(resolve, reject, retry);
      });
    },
    addStyle: function(name = 'style'){
      let style = createElement(core.html[name]());
      document.head.appendChild(style);
      if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
      elements[name] = style;
    },
    html: {
      score: (score) => `<span class="___comment-score___${SCRIPTNAME}">${score}</span>`,
      indicator: () => `
        <div id="${SCRIPTNAME}-indicator"></div>
      `,
      rewind: (rewinded) => `
        <svg id="rewind" ${rewinded ? 'class ="rewinded"' : ''} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.4" class="PlayerSeekBackwardButton-icon">
          <path d="M18.3 29A38 38 0 1 1 23 76.7a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 0 5.7A50 50 0 1 0 8 22.8l-2-1.2a4 4 0 0 0-6 3.5v18.2a4 4 0 0 0 6 3.5L21.7 38a4 4 0 0 0 .2-7L18.3 29zM42 66a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V40h-2a2 2 0 0 1-2-2v-4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v32zm32 0a2 2 0 0 1-2 2H52a2 2 0 0 1-2-2V34c0-1.1.9-2 2-2h20a2 2 0 0 1 2 2v32zm-8-26h-8v20h8V40z"></path>
        </svg>
      `,
      style: () => `
        <style type="text/css">
          /* nicoHighlightColor: ${configs.nicoHighlightColor     = 'rgba(0,128,255,1)'} */
          /* panel_zIndex:       ${configs.panel_zIndex           = 101} */
          /* 流れるコメント透明度 */
          [data-selector="commentCanvas"]{
            transition: opacity 125ms;
          }
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="1"]{opacity: ${(9/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="2"]{opacity: ${(8/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="3"]{opacity: ${(7/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="4"]{opacity: ${(6/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="5"]{opacity: ${(5/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="6"]{opacity: ${(4/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="7"]{opacity: ${(3/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="8"]{opacity: ${(2/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="9"]{opacity: ${(1/9)**1.5}}
          [data-selector="playerDisplayScreen"] [data-selector="commentCanvas"][data-opacity="0"]{opacity: ${(0/9)**1.5}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="1"]{opacity: ${((9/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="2"]{opacity: ${((8/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="3"]{opacity: ${((7/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="4"]{opacity: ${((6/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="5"]{opacity: ${((5/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="6"]{opacity: ${((4/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="7"]{opacity: ${((3/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="8"]{opacity: ${((2/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="9"]{opacity: ${((1/9)**1.5)/4}}
          [data-selector="playerDisplayScreen"]:hover [data-selector="commentCanvas"][data-opacity="0"]{opacity: ${((0/9)**1.5)/4}}
          /* 10秒戻り中の流れるコメント非表示 */
          [data-selector="playerDisplayScreen"] #comment-layer-container{
            transition: opacity 1000ms;
            opacity: 1;
          }
          [data-selector="playerDisplayScreen"][data-rewinded="true"] #comment-layer-container{
            opacity: 0;
          }
          /* インジケータ */
          #${SCRIPTNAME}-indicator{
            position: absolute;
            bottom: 0;
            right: 0;
            padding: 1vh 1vw;
            font-size: 25vh;
            color: ${configs.nicoHighlightColor};
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));
            opacity: 0;
            z-index: ${configs.panel_zIndex};
            pointer-events: none;
            transition: opacity 250ms;
          }
          #${SCRIPTNAME}-indicator.active{
            opacity: .75;
          }
          #${SCRIPTNAME}-indicator #rewind{
            fill: rgba(195,195,195,.5);
            width: 25vh;
            height: 25vh;
          }
          #${SCRIPTNAME}-indicator #rewind.rewinded{
            fill: ${configs.nicoHighlightColor};
          }
          #${SCRIPTNAME}-indicator.active #rewind{
            animation: ${SCRIPTNAME}-blink 2s step-end infinite;
          }
          @keyframes ${SCRIPTNAME}-blink{
            50%{opacity: 0}
          }
          /* 新着コメント停止状態 */
          [class*="_comment-panel_"]:hover::after{
            content: " ";
            position: absolute;
            bottom: 0;
            width: 100%;
            height: 4px;
            animation: ${SCRIPTNAME}-stop 1s linear infinite alternate;
          }
          @keyframes ${SCRIPTNAME}-stop{
            0%{
              background: ${configs.nicoHighlightColor};
            }
            100%{
              background: transparent;
            }
          }
          /* ユーザー発言一覧 */
          dummy [class*="_comment-panel_"] [class*="_table-row_"][data-comment-type="normal"]{
            cursor: pointer;
          }
          /* CSSによる簡易なめらかスクロールの打ち消し */
          [class*="_comment-panel_"] [class*="_table-row_"]:first-child{
            margin-bottom: 0 !important;
            opacity: 1 !important;
            pointer-events: auto !important;
          }
          /* 新着コメントのハイライト */
          [class*="_comment-panel_"] [class*="_table-row_"][data-new="true"]{
            animation: ${SCRIPTNAME}-new 6s linear 1;
          }
          @keyframes ${SCRIPTNAME}-new{
            0%{
              background: rgba(255,255,255,.250);
            }
            100%{
              background: rgba(255,255,255,.000);
            }
          }
          /* NGスコア */
          [class*="_comment-panel_"] [class*="_table-row_"] [class="___comment-score___${SCRIPTNAME}"]{
            visibility: hidden;
            margin: 0 .25em;
          }
          [class*="_comment-panel_"] [class*="_table-row_"]:hover [class="___comment-score___${SCRIPTNAME}"]{
            visibility: visible;
            color: #808080;
          }
        </style>
      `,
    },
  };
  if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  if(!('fullscreenElement' in document)) Object.defineProperty(document, 'fullscreenElement', {get: function(){return document.mozFullScreenElement}});
  class Storage{
    static key(key){
      return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
    }
    static save(key, value, expire = null){
      key = Storage.key(key);
      localStorage[key] = JSON.stringify({
        value: value,
        saved: Date.now(),
        expire: expire,
      });
    }
    static read(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.value === undefined) return data;
      if(data.expire === undefined) return data;
      if(data.expire === null) return data.value;
      if(data.expire < Date.now()) return localStorage.removeItem(key);
      return data.value;
    }
    static delete(key){
      key = Storage.key(key);
      delete localStorage.removeItem(key);
    }
    static saved(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.saved) return data.saved;
      else return undefined;
    }
  }
  const $ = function(s){return document.querySelector(s)};
  const $$ = function(s){return document.querySelectorAll(s)};
  const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  const createElement = function(html = '<span></span>'){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  const linkify = function(node){
    split(node);
    function split(n){
      if(['style', 'script', 'a'].includes(n.localName)) return;
      if(n.nodeType === Node.TEXT_NODE){
        let pos = n.data.search(linkify.RE);
        if(0 <= pos){
          let target = n.splitText(pos);/*pos直前までのnとpos以降のtargetに分割*/
          let rest = target.splitText(RegExp.lastMatch.length);/*targetと続くrestに分割*/
          /* この時点でn(処理済み),target(リンクテキスト),rest(次に処理)の3つに分割されている */
          let a = document.createElement('a');
          let match = target.data.match(linkify.RE);
          switch(true){
            case(match[1] !== undefined): a.href = (match[1][0] == 'h') ? match[1] : 'h' + match[1]; break;
            case(match[2] !== undefined): a.href = 'http://' + match[2]; break;
            case(match[3] !== undefined): a.href = 'mailto:' + match[4] + '@' + match[5]; break;
          }
          a.appendChild(target);/*textContent*/
          rest.parentNode.insertBefore(a, rest);
        }
      }else{
        for(let i = 0; n.childNodes[i]; i++) split(n.childNodes[i]);/*回しながらchildNodesは増えていく*/
      }
    }
  };
  linkify.RE = new RegExp([
    '(h?ttps?://[-\\w_./~*%$@:;,!?&=+#]+[-\\w_/~*%$@:;&=+#])',/*通常のURL*/
    '((?:\\w+\\.)+\\w+/[-\\w_./~*%$@:;,!?&=+#]*)',/*http://の省略形*/
    '((\\w[-\\w_.]+)(?:@|@)(\\w[-\\w_.]+\\w))',/*メールアドレス*/
  ].join('|'));
  const secondsToTime = function(seconds){
    let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0');
    let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60);
    if(h) return h + '時間' + zero(m) + '分' + zero(s) + '秒';
    if(m) return m + '分' + zero(s) + '秒';
    if(s) return s + '秒';
  };
  const atLeast = function(min, b){
    return Math.max(min, b);
  };
  const atMost = function(a, max){
    return Math.min(a, max);
  };
  const between = function(min, b, max){
    return Math.min(Math.max(min, b), max);
  };
  const log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
    //console.log(error.stack);
    console.log(
      SCRIPTNAME + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + line,
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '') + '()',
      ...arguments
    );
  };
  log.formats = [{
      name: 'Firefox Scratchpad',
      detector: /MARKER@Scratchpad/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Console',
      detector: /MARKER@debugger/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 3',
      detector: /\/gm_scripts\//,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 4+',
      detector: /MARKER@user-script:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Tampermonkey',
      detector: /MARKER@moz-extension:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Chrome Console',
      detector: /at MARKER \(<anonymous>/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
    }, {
      name: 'Chrome Tampermonkey',
      detector: /at MARKER \((userscript\.html|chrome-extension:)/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+)\)$/)[1] - 6,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm),
    }, {
      name: 'Edge Console',
      detector: /at MARKER \(eval/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
    }, {
      name: 'Edge Tampermonkey',
      detector: /at MARKER \(Function/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
    }, {
      name: 'Safari',
      detector: /^MARKER$/m,
      getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
      getCallers: (e) => e.stack.split('\n'),
    }, {
      name: 'Default',
      detector: /./,
      getLine: (e) => 0,
      getCallers: (e) => [],
    }];
  log.format = log.formats.find(function MARKER(f){
    if(!f.detector.test(new Error().stack)) return false;
    //console.log('//// ' + f.name + '\n' + new Error().stack);
    return true;
  });
  const time = function(label){
    if(!DEBUG) return;
    const BAR = '|', TOTAL = 100;
    switch(true){
      case(label === undefined):/* time() to output total */
        let total = 0;
        log('Total:');
        Object.keys(time.records).forEach((label) => total += time.records[label].total);
        Object.keys(time.records).forEach((label) => {
          console.log(
            BAR.repeat((time.records[label].total / total) * TOTAL),
            label + ':',
            (time.records[label].total).toFixed(3) + 'ms',
            '(' + time.records[label].count + ')',
          );
        });
        time.records = {};
        break;
      case(!time.records[label]):/* time('label') to create and start the record */
        time.records[label] = {count: 0, from: performance.now(), total: 0};
        break;
      case(time.records[label].from === null):/* time('label') to re-start the lap */
        time.records[label].from = performance.now();
        break;
      case(0 < time.records[label].from):/* time('label') to add lap time to the record */
        time.records[label].total += performance.now() - time.records[label].from;
        time.records[label].from = null;
        time.records[label].count += 1;
        break;
    }
  };
  time.records = {};
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址