// ==UserScript== // @name AbemaTV Screen Comment Scroller // @namespace knoa.jp // @description AbemaTV のコメントをニコニコ風にスクロールさせます。 // @include https://abema.tv/* // @version 2.1.0 // @grant none // @downloadURL none // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'ScreenCommentScroller'; const DEBUG = false;/* [update] コメントの継続取得に失敗したら自動復帰するようにしました。不透明度を透明度に変更しました。 平常時のCPU負荷を低減させました。一覧コメントの「操作していない時は画面外に隠す」時のCPU負荷を低減させました。 設定にスクロールコメントの「最大同時表示数」を加えました。最大同時表示数を0にして、コメントスクロールなしの状態でもご活用いただけます。 ほか軽微な修正。 透明度ごめん 動作が重い場合の対策方法: スクロールコメントの「最大同時表示数」を小さくする スクロールコメントの「全面Canvasで描画(高性能PC向け)」のオンとオフを両方試す 一覧コメントの「操作していない時は画面外に隠す」をオンに NGワードはたくさん登録しすぎない ウィンドウサイズを小さくする ブラウザのハードウェアアクセラレーションが有効になっていることを確認する [to do] 画質落とし挑戦 最大同時表示数は右の一覧コメにも反映…できる?そもそも需要ある? >コメント欄がロードしてドバっと表示、ロードしてドバっと表示の繰り返しなのがダメだわ。 >自分のコメは表示、他人のコメはPC側で間引き表示にしてほしい。 コメントをまとめて取得する(=スクロールのタイミングで表示する) 設定値も作ろう!! 負荷低減のためトランジションは100msで。 番組変わった時点で何かを開いてるとコメが閉じられてしまう? 通知受け取るボタンの位置はz-indexどうにもならんのかな… 最悪左に移しちゃえばいいのかな>>ナビゲーション要素は右側に統一されてる 通知受け取るボタンが番組開始後も取り残されることがある? コメント投稿後の再登場を回避できないか>>再登場する仕様解消した? Transitionと全面Canvasはどちらが未来なのか Transitionに統一しつつTransition特有のバグをつぶすか 背景タブ時に重なりまくる問題 番組表と通知 アドオン拡張化 [not to do] 新着コメント緑ボタン後の表示は現状簡単にはアニメーションさせられない 画面外だといつの間にかコメントが止まるとかそもそも発動しないのはアベマ自体のバグ */ if(window === top && console.time) console.time(SCRIPTNAME); const CONFIGS = [ /* スクロールコメント */ {KEY: 'maxlines', TYPE: 'int', DEFAULT: 10 },/*最大行数(文字サイズ連動)*/ {KEY: 'linemargin', TYPE: 'float', DEFAULT: 0.20 },/*行間(比率)*/ {KEY: 'transparency', TYPE: 'int', DEFAULT: 50 },/*透明度(%)*/ {KEY: 'owidth', TYPE: 'float', DEFAULT: 0.05 },/*縁取りの太さ(比率)*/ {KEY: 'duration', TYPE: 'float', DEFAULT: 5.00 },/*横断にかける秒数*/ {KEY: 'maxcomments', TYPE: 'int', DEFAULT: 100 },/*最大同時表示数*/ {KEY: 'canvas', TYPE: 'bool', DEFAULT: 0 },/*全面Canvasで描画(高性能PC向け)*/ {KEY: 'fps', TYPE: 'int', DEFAULT: 60 },/*全面Canvasでの秒間描画コマ数*/ /* 一覧コメント */ {KEY: 'l_hide', TYPE: 'bool', DEFAULT: 1 },/*操作していない時は画面外に隠す*/ {KEY: 'l_overlay', TYPE: 'bool', DEFAULT: 1 },/*映像に重ねる*/ {KEY: 'l_showtime', TYPE: 'bool', DEFAULT: 1 },/*投稿時刻を表示する*/ {KEY: 'l_width', TYPE: 'float', DEFAULT: 16.5 },/*横幅(%)*/ {KEY: 'lc_maxlines', TYPE: 'int', DEFAULT: 30 },/*最大行数(文字サイズ連動)*/ {KEY: 'lc_linemargin', TYPE: 'float', DEFAULT: 0.50 },/*改行されたコメントの行間(比率)*/ {KEY: 'lc_margin', TYPE: 'float', DEFAULT: 1.65 },/*コメント同士の間隔(比率)*/ {KEY: 'lc_transparency', TYPE: 'int', DEFAULT: 25 },/*文字の透明度(%)*/ {KEY: 'lb_transparency', TYPE: 'int', DEFAULT: 75 },/*背景の透明度(%)*/ /* アベマのナビゲーション */ {KEY: 'n_clickonly', TYPE: 'bool', DEFAULT: 0 },/*画面クリック時のみ表示する*/ {KEY: 'n_delay', TYPE: 'float', DEFAULT: 4.00 },/*隠れるまでの時間(秒)*/ {KEY: 'n_transparency', TYPE: 'int', DEFAULT: 50 },/*透明度(%)*/ ]; const PANELS = ['configPanel', 'ngList', 'ngHelp'];/*パネルの表示順*/ const AINTERVAL = 7;/*AbemaTVのコメント取得間隔の仕様値*/ const CANVASMARGIN = 10;/*canvas内に文字を確実に収めるための余裕*/ /* サイト定義 */ let retry = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/ let site = { targets: [ /* 構造 */ function header(){let header = $('body > div > div > header'); return (header) ? site.use(header) : null;}, function footer(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen.parentNode.parentNode) : null;}, function board(){let board = $('div[aria-hidden] form + div > div'); return (board) ? site.use(board) : null;}, function screen(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.parentNode) : null;}, /* ペイン */ function commentPane(){let form = $('form:not([role="search"])'); return (form) ? site.use(form.parentNode.parentNode) : null;}, function channelPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling) : null;}, function programPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling.nextElementSibling) : null;}, /* ボタン */ function channelButtons(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode) : null;}, function channelButton(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button) : null;}, function commentButton(){let svg = $('use[*|href^="/images/icons/comment.svg"]'); return (svg) ? site.use(svg.parentNode.parentNode) : null;}, function programButton(){let button = $('button[aria-label^="フルスクリーン"] + div + div > div > div'); return (button) ? site.use(button) : null;}, function fullscreenButton(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen) : null;}, function VolumeController(){let mute = $('button[aria-label="音声オンオフ切り替え"]'); return (mute) ? site.use(mute.parentNode.parentNode) : null;}, function closer(){let commentForm = $('form:not([role="search"])'); return (commentForm) ? site.use(commentForm.parentNode.parentNode.nextElementSibling) : null;}, /* 要素 */ function caution(){let header = $('header'); return (header) ? site.use(header.nextElementSibling) : null;}, function commentForm(){let form = $('form:not([role="search"])'); return (form) ? site.use(form) : null;}, function notice(){let buttons = elements.screen.querySelectorAll(selectors.screen + ' > div > div:last-child > button'); for(let i = 0; buttons[i]; i++) site.use(buttons[i].parentNode); return (buttons) ? true : null;}, function audienceTop(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling) : null;}, function audience(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling.firstElementChild) : null;}, function loading(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode) : null;}, function programName(){let name = $('button[aria-label^="フルスクリーン"] + div + div div > p > span > span:last-child'); return (name) ? site.use(name) : null;}, /* セレクタ定義のみ */ function newCommentsButton(){return site.use(null);}, function newComments(){return site.use(null);}, function comment(){return site.use(null);}, ], addedNode: { newCommentsButton: function(node){let button = node.parentNode.querySelector(selectors.commentPane + ' > div > button'); return (button) ? site.use(node) : null;}, newComments: function(node){let wrapper = node.querySelector(selectors.board + ' > div:not([data-selector]) > div'); return (wrapper) ? site.use(node) && Array.from(wrapper.children).map(site.addedNode.comment) : null;}, newComment: function(node){let commentText = node.querySelector(selectors.newComments + ' > div > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : null;}, comment: function(node){let commentText = node.querySelector('div:not([data-selector]) > p:first-child'); return (commentText) ? site.use(node) : null;}, progressbar: function(node){let circle = node.querySelector('svg circle'); return (circle) ? site.use(circle.parentNode.parentNode) : null;}, }, removedNode: { newComments: function(node){return (node.dataset.selector === 'newComments') ? node : null;}, }, get: { comments: function(newComments){return newComments.firstElementChild.children;}, commentText: function(comment){return comment.firstElementChild.textContent;}, }, use: function use(target, key = use.caller.name){ if(target) target.dataset.selector = key; selectors[key] = `[data-selector="${key}"]`; elements[key] = target; return true; }, }; /* 処理本体 */ let html, elements = {}, selectors = {}, ngwords = [], configs = {}; let canvas, context, interval, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/ let core = { /* 初期化 */ initialize: function(){ let previousUrl = ''; /* 一度だけ */ html = document.documentElement; core.config.read(); core.ng.initialize(); core.listenUserActions(); window.addEventListener('resize', core.modify); /* URLの変化を見守る */ setInterval(function(){ if(location.href === previousUrl) return;/*URLが変わってない*/ /* テレビ視聴ページ */ if(location.href.startsWith('https://abema.tv/now-on-air/')){ /* チャンネルを変えただけ */ if(previousUrl.startsWith('https://abema.tv/now-on-air/')){ html.classList.remove('comment'); html.classList.remove('ng'); /* テレビ視聴ページになった */ }else{ core.ready(); } /* テレビ視聴ページではない */ }else{ core.gone(); } previousUrl = location.href; }, 1000); }, /* テレビ視聴ページになるたびに呼ぶ */ ready: function(){ /* 必要な要素が出揃うまで粘る */ for(let i = 0, target; target = site.targets[i]; i++){ if(target() === null){ if(!retry) return log(`Not found: ${target.name}, I give up.`); log(`Not found: ${target.name}, retrying...`); return retry-- && setTimeout(core.ready, 1000); } } log("I'm Ready."); /* すべての要素が出揃っていたので */ core.createCanvas(); core.listenComments(); core.ng.createButton(); core.config.createButton(); if(configs.canvas) core.scrollComments(); core.panel.createPanels(); core.addStyle(); html.classList.add(SCRIPTNAME); /* コメントを開けるようになったら自動で開く */ let url = null; let observer = observe(elements.commentButton, function(records){ if(elements.commentPane.attributes['aria-hidden'].value === 'false') return;/*既に表示中*/ if(getComputedStyle(elements.commentButton).cursor === 'pointer'){ if(url !== location.href){/*チャンネル切り替え後の初回*/ elements.commentButton.click(); url = location.href; }else if(html.classList.contains('comment')){/*コメントを開いた状態で番組開始を迎えたとき*/ setTimeout(function(){elements.commentButton.click()}, 1000); setTimeout(function(){elements.commentButton.click()}, 2000); } } }, {attributes: true}); }, /* テレビ視聴ページから離れたときに呼ぶ */ gone: function(){ if(elements.style) document.head.removeChild(elements.style); html.classList.remove(SCRIPTNAME); }, /* キーボードとマウスイベントを見守る */ listenUserActions: function(){ let id; let timer = function(e){ clearTimeout(id), id = setTimeout(function(){ if(['input', 'textarea', 'button'].includes(document.activeElement.loaclName)) return;/*入力中はアクティブのまま*/ html.classList.remove('active'); }, configs.n_delay * 1000); }; let activate = function(){ if(!html.classList.contains('active')) html.classList.add('active'); timer(); }; window.addEventListener('keydown', function(e){ if(['input', 'textarea'].includes(e.target.localName)) e.stopPropagation(); }, true); window.addEventListener('mousemove', function(e){ if(configs.n_clickonly) return; activate(); }); /* クリックを捉える */ window.addEventListener('click', function(e){/*アベマより先にwindowでキャプチャ*/ switch(e.target){ case(elements.channelButton): return html.classList.toggle('channel'); case(elements.programButton): return html.classList.toggle('program'); case(elements.commentButton): if(html.classList.contains('comment')){ animate(function(){elements.closer.click()});/*すぐクリックすると競合してしまうのでanimate()*/ }else{ html.classList.add('comment'); if(!configs.l_overlay) core.modify(); /* デフォルトのボタン動作が実行される */ } return; case(elements.newCommentsButton): if(e.isTrusted){/*実クリックのみで処理*/ elements.newCommentsButton.style.height = '0'; /* スクロールをなめらかにする */ let scrollTop = elements.board.parentNode.scrollTop; elements.board.style.transition = '500ms ease'; elements.board.style.transform = `translateY(${scrollTop}px)`; elements.board.addEventListener('transitionend', function(e){ elements.board.style.transition = 'none'; elements.board.style.transform = 'translateY(0)'; elements.newCommentsButton.click(); }, {once: true}); e.stopPropagation(); }else{ /* デフォルトのボタン動作が実行される */ } return; case(elements.closer): switch(true){ case(html.classList.contains('channel')): html.classList.remove('channel'); return e.stopPropagation(); case(html.classList.contains('program')): html.classList.remove('program'); return e.stopPropagation(); case(html.classList.contains('comment')): core.ng.closeForm();/*NGフォームを開いているなら閉じる*/ default: if(e.isTrusted){/*実クリックではコメントは閉じない*/ e.stopPropagation(); html.classList.toggle('active'); timer(); }else{/*スクリプトのelements.closer.click()でのみ閉じる*/ html.classList.toggle('comment'); if(!configs.l_overlay) core.modify(); } return; } default: return;/*デフォルトの動作に任せる*/ } }, true); /* コメントペインを隠す設定でもコメント入力中は表示させる */ if(configs.l_hide){ window.addEventListener('focusin', function(e){ if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.classList.add('active'); }); window.addEventListener('focusout', function(e){ if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.classList.remove('active'); }); } /* コメントペインの開閉でcanvasサイズを再計算 */ observe(html, function(records){ if(!configs.l_overlay) core.modify(); }, {attributes: true}); }, /* canvas作成 */ createCanvas: function(){ if(canvas) elements.screen.removeChild(canvas); if(configs.canvas){ canvas = createElement(core.html.canvas()); context = canvas.getContext('2d', {alpha: false}); }else{ canvas = createElement(core.html.canvasDiv()); /* テキストサイズ計測に使用 */ elements.preCanvas = createElement(core.html.preCanvas()); context = elements.preCanvas.getContext('2d', {alpha: false}); } elements.screen.insertBefore(canvas, elements.screen.firstElementChild); core.modify(); }, /* スクリーンサイズを適切に変化させる */ modify: function(){ if(!elements.screen) return;/*フルスクリーン遷移時に対応*/ let fullsize = (configs.l_overlay || !html.classList.contains('comment') || (configs.l_hide && html.classList.contains('comment') && !html.classList.contains('active'))); let width = (fullsize) ? window.innerWidth : Math.round(window.innerWidth * (1 - configs.l_width / 100)); let height = window.innerHeight; elements.screen.style.width = canvas.style.width = width + 'px'; elements.screen.style.height = canvas.style.height = height + 'px'; canvas.width = width; canvas.height = height; canvas.fontsize = Math.round((canvas.height / configs.maxlines) / (1 + configs.linemargin)); context.font = `bold ${canvas.fontsize}px sans-serif`; context.textBaseline = 'middle'; context.fillStyle = 'white'; context.strokeStyle = 'black'; context.lineWidth = Math.round(canvas.fontsize * configs.owidth); context.lineJoin = 'round'; canvas.topDelta = (configs.canvas) ? ((canvas.height / configs.maxlines) / 2) : (((canvas.fontsize * configs.linemargin) - context.lineWidth - CANVASMARGIN) / 2);/*canvasのtop計算に使用する*/ if(configs.canvas){ /* スクロールコメントの再計算 */ for(let i=0; lines[i]; i++){ for(let j=0; lines[i][j]; j++){ lines[i][j].width = context.measureText(lines[i][j].text).width; lines[i][j].ppms = (canvas.width + lines[i][j].width) / (configs.duration * 1000); lines[i][j].top = Math.round(((canvas.height / configs.maxlines) * i) + canvas.topDelta); } } core.scrollComments(); } }, /* コメントの新規追加を見守る */ listenComments: function(){ if(elements.commentPane.isListening) return; elements.commentPane.isListening = true; observe(elements.commentPane.firstElementChild, function(records){ /* 新着コメント表示ボタン */ if (records[0].addedNodes.length === 1 && site.addedNode.newCommentsButton(records[0].addedNodes[0]) !== null){ let newCommentsButton = records[0].addedNodes[0]; if(elements.board.classList.contains('mousedown')){/*テキスト選択を邪魔しないための配慮*/ window.addEventListener('mouseup', function(){ animate(function(){newCommentsButton.classList.add('shown')}); }, {once: true}); }else{ animate(function(){newCommentsButton.classList.add('shown')}); } } }); observe(elements.board, function(records){ let replacedComments = [], ngFormIndex = null; for(let i = 0, record; record = records[i]; i++){ switch(true){ /* 新着コメント集 */ case (record.addedNodes.length === 1 && site.addedNode.newComments(record.addedNodes[0]) !== null): core.receiveNewComments(elements.newComments); observe(elements.newComments.firstElementChild, function(records){ for(let j = 0, record; record = records[j]; j++){ switch(true){ /* 新着単一コメント */ case (record.addedNodes.length === 1 && site.addedNode.newComment(record.addedNodes[0]) !== null): break; } } core.receiveNewComments(elements.newComments); }); break; /* 差し替え単一コメント(newComments内のcommentたちがごっそり新しいNodeに差し替えられてしまうアベマの悲しい仕様) */ case (record.addedNodes.length === 1 && site.addedNode.comment(record.addedNodes[0]) !== null): core.ng.filter(record.addedNodes[0]);/*NGフィルタの再適用*/ replacedComments.push(record.addedNodes[0]); break; /* 差し替えられたNodeの状態を再現する */ case (record.removedNodes.length === 1 && site.removedNode.newComments(record.removedNodes[0]) !== null): /* 開いていたNG登録フォーム */ if(elements.ngForm && elements.ngForm.parentNode.parentNode.parentNode === record.removedNodes[0]){ ngFormIndex = Array.from(site.get.comments(record.removedNodes[0])).indexOf(elements.ngForm.parentNode); } /* 選択していたテキスト(対応しない) */ break; } } if(ngFormIndex !== null) replacedComments[ngFormIndex].appendChild(elements.ngForm); }); }, /* 新着コメントを受け取ったときの処理 */ receiveNewComments: function(newComments){ let getDelay = function(text){ if(DEBUG && !text.endsWith) alert(text);/*text.endsWith is not a functionと言われたので*/ switch(true){ case(text === '今'): return 0; case(text.endsWith('秒前')): return parseInt(text); case(text.endsWith('分前')): return parseInt(text) * 60; case(text.endsWith('時間前')): return parseInt(text) * 60 * 60; default/*日前*/: return 60 * 60 * 24; } }; /* コメントの継続取得に失敗したら自動復帰する */ let recoverComment = function(interval, n = 10){ return setTimeout(function(){ if(!elements.board.children[n - 1]) return; if(getDelay(elements.board.children[n - 1].children[1].textContent) >= interval) return; if(!html.classList.contains('comment')) return;/*コメント表示中でない場合はなにもしない*/ if(getComputedStyle(elements.commentButton).cursor === 'pointer'){ setTimeout(function(){elements.commentButton.click()}, 1); setTimeout(function(){elements.commentButton.click()}, 2); } }, 1000 * interval); }; clearTimeout(elements.board.shortTimer), elements.board.shortTimer = recoverComment(10);/*10件目が10秒以内なのに10秒以上コメがない*/ clearTimeout(elements.board.longTimer), elements.board.longTimer = recoverComment(60);/*10件目が60秒以内なのに60秒以上コメがない*/ /* コメントの取得間隔を計測する(AINTERVAL仕様の変更に備える) */ let now = Date.now(), commentInterval = (now - parseInt(newComments.dataset.received)) / 1000 || AINTERVAL; newComments.dataset.received = now;/*datasetを使うことでnewCommentsがなくなるときはいっしょになくなる*/ /* NGコメントをすぐ判定する */ core.ng.expire(); let filteredComments = Array.from(site.get.comments(newComments)).filter(core.ng.filter); /* スライドダウンアニメーションを上書きする */ core.slideDownNewComments(newComments); /* 投稿経過時間に合わせた自然なばらつきでコメントを流すためのスケジュールを作る */ let schedule = [];/*タイミングだけを格納する配列*/ for(let i = 0; filteredComments[i]; i++){ schedule.push(getDelay(filteredComments[i].lastElementChild.textContent)); } let lastIndex = schedule.length - 1, scale = (commentInterval) / (schedule[lastIndex] - schedule[0] + 1); schedule = schedule.map(/*最古のコメントを0として何秒後に流すべきかの配列を作る*/ (delay, i, s) => s[lastIndex] - delay ).map(/*randomを加えて散らす*/ (delay, i, s) => delay + (Math.random() * ((lastIndex - i) / lastIndex)) ).sort(/*randomで乱れたぶんをソート*/ (a, b) => b - a ).map(/*次のAINTERVALまでばらつきを平準化する*/ (delay, i, s) => delay * scale ); /* スケジュールに沿って配列末尾の古いコメントから順に流す */ for(let i = filteredComments.length - 1; filteredComments[i]; i--){ window.setTimeout(function(){ core.attachComment(filteredComments[i].firstElementChild.textContent); }, 1000 * schedule[i]); } }, /* スライドダウンアニメーションを上書きする */ slideDownNewComments: function(newComments){ newComments.style.maxHeight = '0px';/*heightの上書き戦争を避けてmaxHeightが使えるのは幸運*/ newComments.dataset.naturalHeight = getComputedStyle(newComments.firstElementChild).height; animate(function(){newComments.style.maxHeight = newComments.dataset.naturalHeight}); }, /* コメントが追加されるたびにスクロールキューに追加 */ attachComment: function(text){ if(canvas.children.length > configs.maxcomments) return; let scrollComment, c; let width = Math.round(context.measureText(text).width + context.lineWidth); let height = Math.round(canvas.fontsize + context.lineWidth + CANVASMARGIN); if(!configs.canvas){ scrollComment = createElement(core.html.scrollComment(width, height)); c = scrollComment.getContext('2d'); c.font = `bold ${canvas.fontsize}px sans-serif`; c.textBaseline = context.textBaseline; c.fillStyle = context.fillStyle; c.strokeStyle = context.strokeStyle; c.lineWidth = context.lineWidth; c.lineJoin = context.lineJoin; let padding = Math.round(context.lineWidth / 2); let middle = Math.round(height / 2); c.strokeText(text, padding, middle); c.fillText(text, padding, middle); } let record = {}; record.text = text;/*流れる文字列*/ record.width = 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): record.top = Math.round(((canvas.height / configs.maxlines) * i) + canvas.topDelta); //if(!configs.canvas) scrollComment.dataset.former = JSON.stringify(lines[i][length - 1]); //if(!configs.canvas) scrollComment.dataset.self = JSON.stringify(record); lines[i].push(record); if(!configs.canvas){ scrollComment.style.top = record.top + 'px'; canvas.appendChild(scrollComment); animate(function(){ scrollComment.classList.add('scroll'); scrollComment.addEventListener('transitionend', function(e){ canvas.removeChild(scrollComment); lines[i].shift(); }, {once: true}); }); } return;/*行に追加したら終了*/ default: continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/ } } }, /* Canvas FPSタイマー駆動 */ scrollComments: function(){ /* アニメーション関連は極力浅いオブジェクトに */ let width = canvas.width, height = canvas.height, fps = configs.fps; clearInterval(interval), interval = setInterval(function(){ let now = Date.now(); /* Canvas描画 */ context.clearRect(0, 0, width, height); for(let i = 0, line; line = lines[i]; i++){ for(let j = 0, comment; comment = line[j]; j++){ /* 描画位置を計算 */ comment.left = width - ((now - comment.start) * comment.ppms); /* 視認性を向上させるスクロール文字の縁取りは、幸いにもパフォーマンスにほぼ影響しない */ context.strokeText(comment.text, comment.left, comment.top); context.fillText(comment.text, comment.left, comment.top); } if(line[0] && line[0].end < now) line.shift(); } }, 1000 / fps); }, /* NGワード */ ng: { initialize: function(){ core.ng.read(); core.ng.listenSelection(); }, listenSelection: function(){ /* コメント上でmousedownした状態からのmousemove,mouseupでのみselect() */ let select = function(e){ let selection = window.getSelection(), selected = selection.toString(), comment = (selection.anchorNode) ? selection.anchorNode.parentNode.parentNode : null; /* テキスト選択なしなら登録フォームを閉じる */ if(selection.isCollapsed && e.type === 'mouseup' && !e.target.dataset.ngword) return core.ng.closeForm(); /* テキスト選択を邪魔しない場合にのみ登録フォームを表示 */ if(!elements.ngForm || elements.ngForm.classList.contains('hidden') || e.target.offsetTop < elements.ngForm.offsetTop || e.type === 'mouseup') core.ng.openForm(comment, e); /* テキスト選択があれば初期値に */ if(!selection.isCollapsed) elements.ngForm.querySelector('input[type="text"]').value = selected; }; window.addEventListener('mousedown', function(e){ if(![e.target.dataset.selector, e.target.parentNode.dataset.selector].includes('comment')) return; elements.board.classList.add('mousedown'); window.addEventListener('mousemove', select); window.addEventListener('mouseup', function(e){ animate(function(){select(e)});/*ダブルクリックでのテキスト選択をanimateで確実に補足*/ window.removeEventListener('mousemove', select); elements.board.classList.remove('mousedown'); }, {once: true}); }); }, createButton: function(){ if(elements.ngButton) return; /* フルスクリーンボタンを元にNG一覧ボタンを追加する */ elements.ngButton = createElement(core.html.ngButton()); elements.ngButton.className = elements.fullscreenButton.className; elements.ngButton.addEventListener('click', core.panel.toggle.bind(null, 'ngList', core.ng.createList)); elements.fullscreenButton.parentNode.insertBefore(elements.ngButton, elements.fullscreenButton); }, createForm: function(comment){ elements.ngForm = createElement(core.html.ngForm()); elements.ngForm.querySelector('button.list').addEventListener('click', core.panel.toggle.bind(null, 'ngList', core.ng.createList)); elements.ngForm.querySelector('button.help').addEventListener('click', core.panel.toggle.bind(null, 'ngHelp', core.ng.createHelp)); elements.ngForm.querySelector('p.type').addEventListener('click', function(e){ let word = elements.ngForm.querySelector('p.word input'); if(word.value === '') return; if(e.target.localName !== 'button') return; core.ng.add(word, e.target); core.ng.closeForm(); if(elements.ngList) core.ng.buildList(); }); }, openForm: function(comment, e){ let slideUpDown = function(){ elements.ngForm.slidingUp = true; animate(function(){ elements.ngForm.classList.add('hidden'); if(elements.ngForm.isConnected){ elements.ngForm.addEventListener('transitionend', function(e){ elements.ngForm.slidingUp = false; elements.ngForm.targetComment.appendChild(elements.ngForm); slideDown(); }, {once: true}); }else{ elements.ngForm.slidingUp = false; elements.ngForm.targetComment.appendChild(elements.ngForm); slideDown(); } }); }; let slideDown = function(){ elements.ngForm.slidingDown = true; if(elements.ngForm.parentNode !== elements.ngForm.targetComment) elements.ngForm.targetComment.appendChild(elements.ngForm); animate(function(){ elements.ngForm.classList.remove('hidden'); elements.ngForm.addEventListener('transitionend', function(e){ elements.ngForm.slidingDown = false; }, {once: true}); }); let ngword = elements.ngForm.targetComment.dataset.ngword; if(ngword && e.type === 'click') elements.ngForm.querySelector('input[type="text"]').value = ngword; if(!html.classList.contains('ng')) html.classList.add('ng');/*チャンネル切り替えナビゲーションを隠すなど*/ }; if(elements.board.parentNode.scrollTop === 0) elements.board.parentNode.scrollTop = 1;/*新着コメントを停止する*/ if(elements.ngForm){/*表示位置の移し替え*/ elements.ngForm.targetComment = comment;/*既にslideDown中の処理も含めてターゲットを差し替える*/ if(elements.ngForm.classList.contains('hidden')){ if(elements.ngForm.slidingUp){/*Up中*/ if(elements.ngForm.parentNode === comment){ slideDown();/*UpをやめてDownさせる*/ }else{ /*予定通りUp後にDownさせる*/ elements.ngForm.addEventListener('transitionend', function(e){ slideDown(); }, {once: true}); } }else{/*hidden状態*/ slideDown(); } }else{ if(elements.ngForm.slidingDown){/*Down中*/ if(elements.ngForm.parentNode === comment){ /*なにもしなくてもよい*/ }else{ slideUpDown();/*Downをやめて改めてUpDownさせる*/ } }else{/*表示状態*/ if(elements.ngForm.parentNode === comment){ /*なにもしなくてもよい*/ }else{ slideUpDown(); } } } }else{/*新規*/ core.ng.createForm(comment); elements.ngForm.classList.add('hidden'); elements.ngForm.targetComment = comment; slideDown(); } }, closeForm: function(){ if(!elements.ngForm) return; if(elements.ngForm.classList.contains('hidden')) return; elements.ngForm.slidingUp = true; animate(function(){ elements.ngForm.classList.add('hidden'); if(elements.ngForm.isConnected){ elements.ngForm.addEventListener('transitionend', function(e){ elements.ngForm.slidingUp = false; }, {once: true}); }else{ elements.ngForm.slidingUp = false; } }); html.classList.remove('ng');/*チャンネル切り替えナビゲーションを隠すなど*/ }, toggleForm: function(comment, e){ if(!elements.ngForm) return core.ng.openForm(comment, e); if(elements.ngForm.classList.contains('hidden')) return core.ng.openForm(comment, e); if(elements.ngForm.parentNode !== comment) return core.ng.openForm(comment, e); core.ng.closeForm(); }, createList: function(){ let ngList = elements.ngList = createElement(core.html.ngList()); ngList.querySelector('button.help').addEventListener('click', core.panel.toggle.bind(null, 'ngHelp', core.ng.createHelp)); ngList.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'ngList')); ngList.querySelector('button.save').addEventListener('click', function(e){ core.ng.save(core.ng.getNewNgwords().filter((ngword) => (ngword.type !== 'remove'))); core.panel.close('ngList'); }); ngList.querySelector('ul > li.add > p.words > textarea').addEventListener('keypress', function check(e){ animate(function(){ let checked = ngList.querySelector('ul > li.add > p.type input:checked'); if(e.target.value === '') return checked && (checked.checked = false); if(!checked) ngList.querySelector('ul > li.add > p.type input[value="forever"]').checked = true; }); }); /* 並べ替え */ configs.ng_sort = configs.ng_sort || {key: 'date', reverse: false}; ngList.querySelector('p.sort').addEventListener('click', function(e){ if(e.target.localName !== 'label') return; let input = document.getElementById(e.target.htmlFor); if(input.checked) input.classList.toggle('reverse'); configs.ng_sort = {key: input.value, reverse: input.classList.contains('reverse')}; core.ng.buildList(); }); /* リスト構築 */ core.ng.buildList(); /* 表示 */ core.panel.open('ngList'); }, getNewNgwords: function(){ let new_ngwords = Array.from(ngwords);/*clone*/ let lis = elements.ngList.querySelectorAll('ul > li.edit'); for(let i = 0, li; li = lis[i]; i++){ let word = li.querySelector('p.word input'); let checked = li.querySelector('p.type input:checked'); let match = word.value.match(/^\/(.+)\/([a-z]+)?$/); new_ngwords[i] = {}; new_ngwords[i].value = (match) ? word.value : normalize(word.value).toLowerCase(); new_ngwords[i].regex = (match) ? new RegExp(match[1], match[2]) : null; new_ngwords[i].type = checked.value; new_ngwords[i].added = parseInt(li.dataset.added) || null; new_ngwords[i].limit = (checked.value === 'for24h') ? parseInt(li.dataset.limit) : null; } let add = elements.ngList.querySelector('ul > li.add'); let textarea = add.querySelector('p.words textarea'); let lines = textarea.value.split('\n'); for(let i = 0; lines[i] !== undefined; i++){ let checked = add.querySelector('p.type input:checked'); let match = lines[i].match(/^\/(.+)\/([a-z]+)?$/); let index = new_ngwords.length; new_ngwords[index] = {}; new_ngwords[index].value = (match) ? lines[i] : normalize(lines[i]).toLowerCase(); new_ngwords[index].regex = (match) ? new RegExp(match[1], match[2]) : null; new_ngwords[index].type = (checked) ? checked.value : null; new_ngwords[index].added = Date.now() + i;/*並べ替え用に同一時刻を避ける*/ new_ngwords[index].limit = (checked && checked.value === 'for24h') ? new_ngwords[index].added + 1000*60*60*24 : null; } textarea.value = ''; return new_ngwords.filter((ngword, index) => { if(ngword.value === '') return false;/*空欄除外*/ for(let i = index + 1; new_ngwords[i]; i++) if(ngword.value === new_ngwords[i].value) return false;/*重複除外*/ return true; }); }, buildList: function(){ /* 編集中の既存のリストがあればそのまま使う */ let new_ngwords = core.ng.getNewNgwords(); /* 並べ替え */ if(new_ngwords.length < 2){ elements.ngList.querySelector('p.sort').classList.add('disabled'); }else{ elements.ngList.querySelector('p.sort').classList.remove('disabled'); let sort = elements.ngList.querySelector(`p.sort input[value="${configs.ng_sort.key}"]`); sort.checked = true; if(configs.ng_sort.reverse) sort.classList.add('reverse'); } new_ngwords.sort(function(a, b){ let types = {trial: 1, for24h: 2, forever: 3, remove: 4}; switch(configs.ng_sort.key){ case('date'): return (a.added < b.added); case('word'): return (a.value < b.value); case('type'): return (a.limit && b.limit) ? (a.limit < b.limit) : (types[a.type] < types[b.type]); } }); if(configs.ng_sort.reverse) new_ngwords.reverse(); /* リスト構築 */ let ul = elements.ngList.querySelector('ul'); while(2 < ul.children.length) ul.removeChild(ul.children[1]);/*冒頭のテンプレートと追加登録のみ残す*/ let template = ul.querySelector('li.template'); let now = Date.now(); let formatTime = function(limit){ let left = limit - now; switch(true){ case(1000*60*60 <= left): return Math.floor(left/(1000*60*60)) + '時間'; case(0 <= left): return Math.floor(left/(1000*60)) + '分'; case(left < 0): return '0分'; } }; for(let i = 0, new_ngword; new_ngword = new_ngwords[i]; i++){ let li = template.cloneNode(true); li.className = 'edit'; li.innerHTML = li.innerHTML.replace(/\{i\}/g, i); li.querySelector('p.word input').value = new_ngword.value; if(new_ngword.type) li.querySelector(`p.type input[value="${new_ngword.type}"]`).checked = true; li.dataset.added = new_ngword.added || 0; li.dataset.limit = new_ngword.limit || 0; let for24h = li.querySelector('p.type label.for24h'); for24h.textContent = (new_ngword.limit) ? formatTime(new_ngword.limit) : '24時間'; for24h.addEventListener('click', function(e){ animate(function(){/*checked処理の後に*/ if(li.querySelector('p.type input[value="for24h"]').checked){ if(for24h.classList.toggle('extended')){ li.dataset.limit = Date.now() + 1000*60*60*24; for24h.textContent = '24時間'; }else{ li.dataset.limit = new_ngword.limit; for24h.textContent = formatTime(new_ngword.limit); } } }); }); ul.insertBefore(li, template.nextElementSibling); } }, createHelp: function(){ elements.ngHelp = createElement(core.html.ngHelp()); elements.ngHelp.querySelector('button.ok').addEventListener('click', core.panel.close.bind(null, 'ngHelp')); core.panel.open('ngHelp'); }, add: function(word, type){ let index = ngwords.length; for(let i = 0; ngwords[i]; i++) if(ngwords[i].value === word.value) index = i;/*重複させない*/ let match = word.value.match(/^\/(.+)\/([a-z]+)?$/); if(!ngwords[index]) ngwords[index] = {}; ngwords[index].value = (match) ? word.value : normalize(word.value).toLowerCase(); ngwords[index].regex = (match) ? new RegExp(match[1], match[2]) : null; ngwords[index].type = type.classList[0]; ngwords[index].added = ngwords[index].added || Date.now(); switch(true){ case(type.classList.contains('for24h') && !ngwords[index].limit): case(type.classList.contains('for24h') && type.classList.contains('extended')): ngwords[index].limit = ngwords[index].added + 1000*60*60*24; break; case(type.classList.contains('for24h')): ngwords[index].limit = ngwords[index].limit; break; default: ngwords[index].limit = null; break; } Storage.save('ngwords', ngwords); }, read: function(){ /* 保存済みの設定を読む */ ngwords = Storage.read('ngwords') || []; /* 正規表現(word.regex)はJSONに保存されないので復活させる */ for(let i = 0; ngwords[i]; i++){ let match = ngwords[i].value.match(/^\/(.+)\/([a-z]+)?$/); ngwords[i].regex = (match) ? new RegExp(match[1], match[2]) : null; } }, save: function(new_ngwords){ ngwords = new_ngwords; Storage.save('ngwords', ngwords); }, expire: function(){ let now = Date.now(); ngwords = ngwords.filter(function(ngword, i, ngwords){ if(!ngword.limit || now < ngword.limit) return true; }); }, filter: function(comment){ const match = function(comment, ngword){ let commentText = site.get.commentText(comment); if(ngword.regex && ngword.regex.test(commentText)) return true; if(normalize(commentText).toLowerCase().includes(ngword.value)) return true; }; for(let i = 0, ngword; ngword = ngwords[i]; i++){ switch(ngword.type){ case('forever'): case('for24h'): if(match(comment, ngword)){ comment.classList.add('ng-deleted'); return false; } break; case('trial'): if(match(comment, ngword)){ comment.classList.add('ng-trial'); comment.dataset.ngword = ngword.value; comment.addEventListener('click', function(e){ if(e.target === comment && window.getSelection().isCollapsed) core.ng.toggleForm(comment, e); }); } break; } } return true; }, }, /* 設定 */ config: { read: function(){ /* 保存済みの設定を読む */ configs = Storage.read('configs') || {}; /* 未定義項目をデフォルト値で上書きしていく */ for(let i = 0, config; config = CONFIGS[i]; i++) if(configs[config.KEY] === undefined) configs[config.KEY] = config.DEFAULT; }, save: function(new_config){ configs = {};/*CONFIGSに含まれた設定値のみ保存する*/ /* CONFIGSを元に文字列を型評価して値を格納していく */ for(let i = 0, config; config = CONFIGS[i]; i++){ /* 値がなければデフォルト値 */ if(new_config[config.KEY] === ""){ configs[config.KEY] = config.DEFAULT; continue; } switch(config.TYPE){ case 'bool': configs[config.KEY] = (new_config[config.KEY]) ? 1 : 0; break; case 'int': configs[config.KEY] = parseInt(new_config[config.KEY]); break; case 'float': configs[config.KEY] = parseFloat(new_config[config.KEY]); break; case 'string': default: configs[config.KEY] = new_config[config.KEY]; break; } } Storage.save('configs', configs); }, createButton: function(){ if(elements.configButton) return; /* フルスクリーンボタンを元に設定ボタンを追加する */ elements.configButton = createElement(core.html.configButton()); elements.configButton.className = elements.fullscreenButton.className; elements.configButton.addEventListener('click', core.panel.toggle.bind(null, 'configPanel', core.config.createPanel)); elements.fullscreenButton.parentNode.insertBefore(elements.configButton, elements.ngButton); }, createPanel: function(){ elements.configPanel = createElement(core.html.configPanel()); elements.configPanel.querySelector('button.cancel').addEventListener('click', core.panel.close.bind(null, 'configPanel')); elements.configPanel.querySelector('button.save').addEventListener('click', function(e){ let inputs = elements.configPanel.querySelectorAll('input'), new_configs = {}; for(let i = 0, input; input = inputs[i]; i++){ if(input.type === 'checkbox') new_configs[input.name] = (input.checked) ? 1 : 0; else new_configs[input.name] = input.value; } core.config.save(new_configs); core.panel.close('configPanel') /* 新しい設定値で再スタイリング */ core.addStyle(); }, true); elements.configPanel.querySelector('input[name="canvas"]').addEventListener('click', function(e){ let fps = elements.configPanel.querySelector('input[name="fps"]'); fps.disabled = !fps.disabled; }, true); core.panel.open('configPanel'); }, }, /* パネル共通 */ panel: { createPanels: function(){ if(elements.panels) return; elements.panels = createElement(core.html.panels()); elements.panels.dataset.panels = 0; document.body.appendChild(elements.panels); }, open: function(key){ let target = null; for(let i = PANELS.indexOf(key) + 1; PANELS[i] && !target; i++) if(elements[PANELS[i]]) target = elements[PANELS[i]]; elements[key].classList.add('hidden'); elements.panels.insertBefore(elements[key], target); animate(function(){ elements.panels.dataset.panels = parseInt(elements.panels.children.length); elements[key].classList.remove('hidden'); }); elements.panels.listeningKeypress = elements.panels.listeningKeypress || []; if(!elements.panels.listeningKeypress[key]){ elements.panels.listeningKeypress[key] = true; window.addEventListener('keypress', function(e){ if(elements[key] && e.key === 'Escape') core.panel.close(key); }); } }, close: function(key){ elements[key].classList.add('hidden'); elements[key].addEventListener('transitionend', function(){ elements.panels.dataset.panels = parseInt(elements.panels.children.length - 1); elements.panels.removeChild(elements[key]); elements[key] = null; }, {once: true}); }, toggle: function(key, create){ (!elements[key]) ? create() : core.panel.close(key); }, }, addStyle: function(){ let style = createElement(core.html.style()); document.head.appendChild(style); if(elements.style) document.head.removeChild(elements.style); elements.style = style; }, html: { canvas: () => ` `, canvasDiv: () => `
`, preCanvas: () => ` `, scrollComment: (width, height) => ` `, ngButton: () => ` `, ngForm: () => `
登録したワードを含むコメントを削除します。
お試しの場合はコメント一覧でハイライトされます。
右下の一覧ボタンやコメントのテキスト選択から登録できます。
英数字と記号とカタカナは全角半角や大文字小文字を区別しません。
下記のような正規表現も使えます。
NG
/^NG/
/です$/
/^NGです$/
/^.$/
/.{30}/
/^[a-z]+$/i
/[0-9]{3}/