您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
AbemaTV のコメントをニコニコ風にスクロールさせます。
当前为
// ==UserScript== // @name AbemaTV Screen Comment Scroller // @namespace knoa.jp // @description AbemaTV のコメントをニコニコ風にスクロールさせます。 // @include https://abema.tv/* // @version 1.3.3 // @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.rm_e')}, getComments: function(node){return (node.querySelectorAll) ? node.querySelectorAll('div.rm_bs p.GF_lm') : 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];/*同時取得の中で最初に投稿されたコメントの経過時間*/ if(earliest === undefined) earliest = 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.sC_s7, div.sC_s7 > div{ width: 100% !important; height: 100% !important; } /* 右コメント一覧を透明に */ div.sC_sX{ 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.sC_sX:hover{ background: rgba(0,0,0,${configs.lb_hopacity}); } div.sC_sX::after{ pointer-events: none; position: absolute; content: ""; left: 0px; top: 0px; height: 100%; width: 100%; background: linear-gradient(transparent 50%, gray); } div.sC_sX *{ background: transparent; color: rgba(255,255,255,${configs.lt_opacity}); } div.sC_sX:hover *{ color: rgba(255,255,255,${configs.lt_hopacity}); } /* 右コメント一覧のスクロールバーを美しく */ div.sC_sX > div > div{ overflow-y: hidden; } div.sC_sX:hover > div > div{ overflow-y: auto; } div.sC_sX > div > div::-webkit-scrollbar{ background: rgba(255,255,255,0); } div.sC_sX > div > div::-webkit-scrollbar-thumb{ background: rgba(255,255,255,${configs.lt_hopacity/2}); } /* マウスオーバー時だけナビゲーションを表示させる */ body div.sC_sP{ transform: translateY(200%); } body:hover div.sC_sP{ 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: 0; transform: translate(-50%, 100%); } #${SCRIPTNAME}-config-panel h1, #${SCRIPTNAME}-config-panel legend, #${SCRIPTNAME}-config-panel p{ color: rgba(255,255,255,1); font-size: 14px; padding: 4px 10px; line-height:20px; } #${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); })();