// ==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 = true;
// 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);
})();