// ==UserScript== // @name AbemaTV Screen Comment Scroller // @namespace knoa.jp // @description AbemaTV のコメントをニコニコ風にスクロールさせます。 // @include https://abema.tv/* // @version 1.3.1 // @grant none // @downloadURL 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 = ` `; }, panelHtml: function(){ return innerHTML = `

${SCRIPTNAME}設定

スクロールコメント

一覧コメント

アニメーション

Icon made from Icon Fonts is licensed by CC BY 3.0

`; }, }, addStyle: function(){ if(style) document.head.removeChild(style); (function(css){ style = document.createElement('style'); style.type = 'text/css'; style.textContent = css.replace(/^`); }, }; 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); })();