// ==UserScript== // @name AbemaTV Screen Comment Scroller // @namespace knoa.jp // @description AbemaTV のコメントをニコニコ風にスクロールさせます。 // @include https://abema.tv/* // @version 2.8.2 // @grant none // @downloadURL none // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'ScreenCommentScroller'; const DEBUG = false;/* [update] コメント入力欄下のTwitter連携の行は、コメント入力中のみ表示させるようにしました。 [to do] コメント一覧のNGワードまわり, 特にChromeで検証 お試し登録のクリックからの編集ができない 言葉を替えると追加登録になってしまう ドラッグ選択時に1px停止スクロールが効いてない? 番組開始直後のコメントにセレクタが付かないので一瞬レイアウトが崩れるううう。 現行オブザーバログには引っかかってない。どこにも引っかからずに忽然と現れると言うことは…? 累計500バグが解消されてるかどうか随時確認。 timeを活用して自然に流せるか確認。 > NGワードにかかったIDは自動的にNGIDに放り込むようにしてるからID変えられても大したことはない > 一定期間使用されなかったNGIDは自動で削除されるようにしてるから無限にNGIDが増え続けることもほぼない [to research] Windowsでも画面最下部マウスで耐えられる? 通知を受け取るボタン z-indexどうにもならんのかな… 最悪左に移しちゃえばいいのかな>>ナビゲーション要素は右側に統一されてる && 公式の映像案内が右下前提 番組開始後も取り残されることがある? > 番宣CM中に通知を受け取るをクリックしても次に同じCMがあったときにまたボタンが出てくるのって前からだった? せめてコメントペインが隠れてるかどうかで位置合わせを... パフォーマンス 結局テキストトランジションなら軽い可能性? テキストならscaleが効く -webkit-text-strokeが使えるっぽい そこまでやるならiframeも復活だろうか>>せめてbody直下がよいみたい コメントペインの背景用divを挿入してopacity制御にすれば…? 同じレイヤーにしないと非効率。 [common] 最小フォントサイズ可変対応 Firefoxアドオン Chrome拡張化 つぶやく Qiitaで解説 [possible] 4:3の時にずらせる? greasemonkey 4 系対応 ユーザーブロックアイコンのアニメーション ビデオでのコメント取得 [requests] 設定のナビゲーションに「マウスを近づけたら表示する」 設定のスクロールコメントに「画面下部の専用領域に流す」「高さ(%)」 設定の一覧コメントに「コメントをひとつずつ表示する」 一覧コメントの横幅「%」以外も指定可能に [not to do] 新着コメント緑ボタン後の表示は現状簡単にはアニメーションさせられない 設定のスクロールコメントに「フォント」< div > div > header'); return (header) ? site.use(header) : false;}, function footer(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen.parentNode.parentNode) : false;}, function board(){let board = $('div[aria-hidden] form + div > div'); return (board) ? site.use(board) : false;}, function screen(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.parentNode) : false;}, /* ペイン */ function commentPane(){let form = $('form:not([role="search"])'); return (form) ? site.use(form.parentNode.parentNode) : false;}, function channelPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling) : false;}, function programPane(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling.nextElementSibling) : false;}, /* ボタン */ function channelButtons(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode) : false;}, function channelButton(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button) : false;}, function commentButton(){let svg = $('use[*|href^="/images/icons/comment.svg"]'); return (svg) ? site.use(svg.parentNode.parentNode) : false;}, function programButton(){let button = $('button[aria-label^="フルスクリーン"] + div + div > div > div'); return (button) ? site.use(button) : false;}, function fullscreenButton(){let fullscreen = $('button[aria-label^="フルスクリーン"]'); return (fullscreen) ? site.use(fullscreen) : false;}, function VolumeController(){let mute = $('button[aria-label="音声オンオフ切り替え"]'); return (mute) ? site.use(mute.parentNode.parentNode) : false;}, function closer(){let buttons = $$('[data-selector="screen"] > div > button'); Array.from(buttons).forEach((b) => site.use(b, 'closer')); return (buttons) ? true : false;}, /* 要素 */ function enquete(){let button = $('button[aria-label="放送中の裏番組"]'); return (button) ? site.use(button.parentNode.parentNode.nextElementSibling.nextElementSibling.nextElementSibling) : false;}, function caution(){let header = $('header'); return (header) ? site.use(header.nextElementSibling) : false;}, function commentForm(){let form = $('form:not([role="search"])'); return (form) ? site.use(form) : false;}, function notice(){let buttons = $$('[data-selector="screen"] > div > div:last-child > button'); Array.from(buttons).forEach((b) => site.use(b.parentNode, 'notice')); return (buttons) ? true : false;}, function audienceTop(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling) : false;}, function audience(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode.previousElementSibling.firstElementChild) : false;}, function loading(){let loading = $('img[src="/images/misc/feed_loading.gif"]'); return (loading) ? site.use(loading.parentNode) : false;}, function programName(){let name = $('button[aria-label^="フルスクリーン"] + div + div div > p > span > span:last-child'); return (name) ? site.use(name) : false;}, ], addedNode: { newCommentsButton: function(node){let button = node.parentNode.querySelector('[data-selector="commentPane"] > div > button'); return (button) ? site.use(node, 'newCommentsButton') : false;}, newComments: function(node){let commentText = node.querySelector('[data-selector="board"] > div:not([data-selector]) > div > div > div > p'); return (commentText) ? site.use(node, 'newComments') && Array.from(commentText.parentNode.parentNode.parentNode.children).map(site.addedNode.comment) : false;}, newComment: function(node){let commentText = node.querySelector('[data-selector="newComments"] > div > div > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : false;}, comment: function(node){let commentText = node.querySelector('div:not([data-selector]) > div > p:first-child'); return (commentText) ? site.use(node, 'comment') : false;}, progressbar: function(node){let circle = node.querySelector('svg circle'); return (circle) ? site.use(circle.parentNode.parentNode, 'progressbar') : false;}, }, removedNode: { newComments: function(node){return (node.dataset.selector === 'newComments') ? node : false;}, comment: function(node){return (node.dataset.selector === 'comment') ? node : false;}, }, get: { comments: function(newComments){return newComments.firstElementChild.children;}, commentText: function(comment){return comment.firstElementChild.firstElementChild.textContent;}, commentTime: function(comment){return comment.querySelector('time').textContent;}, commentBlock: function(comment){return comment.querySelector('button[title="ブロック"]');}, commentBlockCancel: function(comment){return comment.querySelector('form button');}, latestTimeStamp: function(newComments){return newComments.querySelector('time').dateTime;}, timeStamp: function(comment){return comment.querySelector('time').dateTime;}, view: function(audience){return audience.querySelector('[data-selector="audience"] > span');}, comment: function(commentButton){return commentButton.querySelector('[data-selector="commentButton"] > span');}, closer: function(){ /* チャンネル切り替えごとに変わる */ let buttons = $$('[data-selector="closer"]'); for(let i = 0; buttons[i]; i++){ if(buttons[i].clientWidth) return buttons[i]; } }, statsApi: function(){ /* アベマの仕様に依存しまくり */ if(!window.dataLayer) return; const API = 'https://api.abema.io/v1/broadcast/slots/{id}/stats'; for(let i = window.dataLayer.length - 1; window.dataLayer[i]; i--){ if(window.dataLayer[i].slotId) return API.replace('{id}', window.dataLayer[i].slotId); } }, }, cmNow: function(){ return (elements.programName && elements.programName.textContent === ''); }, use: function use(target = null, key = use.caller.name){ if(target) target.dataset.selector = key; elements[key] = target; return target; }, }; /* 処理本体 */ let html, elements = {}, ngwords = [], configs = {}; let canvas, context, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/ let core = { initialize: function(){ html = document.documentElement; core.config.read(); core.ng.initialize(); core.listenUserActions(); core.checkUrl(); }, checkUrl: function(){ let previousUrl = ''; 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'); elements.closer = site.get.closer(); }else{/*テレビ視聴ページになった*/ core.ready(); } }else{/*テレビ視聴ページではない*/ core.gone(); } previousUrl = location.href; }, 1000); }, ready: function(){ /* 必要な要素が出揃うまで粘る */ for(let i = 0, target; target = site.targets[i]; i++){ if(target() === false){ if(!retry) return log(`Not found: ${target.name}, I give up.`); log(`Not found: ${target.name}, retrying...`); return retry-- && setTimeout(core.ready, 1000); } } elements.closer = site.get.closer(); log("I'm Ready."); /* すべての要素が出揃っていたので */ core.setupFullscreenButton(); core.createCanvas(); core.listenComments(); core.ng.createButton(); core.config.createButton(); core.panel.createPanels(); core.addStyle(); html.classList.add(SCRIPTNAME); core.observeCommentButton(); }, gone: function(){ if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style); html.classList.remove(SCRIPTNAME); html.classList.remove('comment'); }, setupFullscreenButton: function(){ let full_screen = elements.fullscreenButton.querySelector('use'); let mini_screen = createElement(core.html.mini_screen()); full_screen.parentNode.appendChild(mini_screen); full_screen.parentNode.outerHTML = full_screen.parentNode.outerHTML;/*svgバグ回避*/ elements.fullscreenButton.dataset.icon = 'full_screen'; }, observeCommentButton: function(){ /* コメントを開けるようになったら自動で開く */ 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') return;/*まだクリックできない*/ if(url !== location.href){/*チャンネル切り替え後の初回*/ elements.commentButton.click(); url = location.href; }else if(html.classList.contains('comment')){/*コメントを開いた状態で番組開始を迎えたとき*/ core.closeOpenCommentPane(); } }, {attributes: true}); }, closeOpenCommentPane: function(){ /* コメントが閉じられたと認識されたら即開き直す準備 */ let observer = observe(elements.commentPane, function(records){ if(elements.commentPane.attributes['aria-hidden'].value === 'false') return; observer.disconnect();/*一度だけ*/ elements.commentButton.click(); elements.commentPane.classList.remove('keep'); canvas.classList.remove('keep'); }, {attributes: true}); /* ユーザーには閉じたように見せない */ canvas.classList.add('keep'); elements.commentPane.classList.add('keep'); elements.closer.click(); }, updateStats: function(){ /* mはアベマの仕様に合わせて小文字。しかし小数第1位は0も表示する。 */ let formatNumber = function(number){ switch(true){ case(number < 1e3): return (number); case(number < 1e6): return (number/1e3).toFixed(1) + 'k'; default: return (number/1e6).toFixed(1) + 'm'; } }; let api = site.get.statsApi(); if(!api) return log('Failed: site.get.statsApi.'); let xhr = new XMLHttpRequest(); xhr.open('GET', api); xhr.responseType = 'json'; xhr.onreadystatechange = function(){ if(xhr.readyState !== 4 || xhr.status !== 200) return; if(!xhr.response.stats || !xhr.response.stats.view || !xhr.response.stats.comment) return log(`Not found: stats`); //log('xhr.response:', xhr.response); site.get.view(elements.audience).textContent = formatNumber(xhr.response.stats.view); site.get.comment(elements.commentButton).textContent = formatNumber(xhr.response.stats.comment); }; xhr.send(); }, listenUserActions: function(){ let id, timer = function(e){ clearTimeout(id), id = setTimeout(function(){ if(['input', 'textarea'].includes(document.activeElement.loaclName)) return;/*入力中はアクティブのまま*/ html.classList.remove('active'); if(!configs.l_overlay && configs.l_hide) core.modify(); }, configs.n_delay * 1000); }; window.addEventListener('mousemove', function(e){ if(configs.n_clickonly) return; if(!html.classList.contains('active')){ html.classList.add('active'); if(!configs.l_overlay && configs.l_hide) animate(core.modify); } timer(); }); 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; if(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}); }else{ elements.newCommentsButton.click(); } e.stopPropagation(); }else{ /* デフォルトのボタン動作が実行される */ } return; case(elements.fullscreenButton): if(!document.fullscreen){ document.body.requestFullscreen(); }else{ document.exitFullscreen(); } e.stopPropagation(); return; case(elements.closer): if(html.classList.contains('comment')) core.ng.closeForm();/*NGフォームを開いているなら閉じる*/ if(elements.commentPane.classList.contains('keep')) return html.classList.remove('comment');/*core.closeOpenCommentPaneですぐまた開かれる*/ 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(); default: if(e.isTrusted){/*実クリックではコメントは閉じない*/ e.stopPropagation(); html.classList.add('click');/*250msのtransition遅延をなくしてからキビキビactivate*/ animate(function(){ html.classList.toggle('active'); elements.header.addEventListener('transitionend', function(e){ html.classList.remove('click'); }, {once: true}); if(!configs.l_overlay) core.modify(); }); timer(); }else{/*elements.closer.click()でのみ閉じる*/ html.classList.remove('comment'); if(!configs.l_overlay) core.modify(); /* default and propagateする */ } return; } default: return;/*デフォルトの動作に任せる*/ } }, true); /* アベマ公式ブロックを「コメントクリックでトグル」に差し替える */ window.addEventListener('click', function(e){ if(!e.isTrusted) return; let comment; for(let target = e.target; target; target = target.parentNode){ if(target.localName === 'form') return;/*アベマ公式ブロックフォーム*/ if(target === elements.board) return; if(target === document.body) return; if(target.dataset.selector === 'comment'){ comment = target; break; } } if(!comment) return; let cancel = site.get.commentBlockCancel(comment); if(!cancel){ /* ブロックフォームを開く */ site.get.commentBlock(comment).click(); comment.style.transition = 'background 500ms ease'; comment.classList.add('blockform'); let observer = observe(comment, function(records){ if(site.get.commentBlockCancel(comment)) return; comment.classList.remove('blockform'); observer.disconnect(); }); }else{ /* ブロックフォームを閉じる */ comment.classList.remove('blockform'); comment.addEventListener('transitionend', function(e){ comment.style.transition = 'none'; cancel.click(); }); } }, true); /* 番組開始のタイミングを挟んだバックグラウンドからの復帰でコメント取得が停止する現象を防ぐ */ document.addEventListener('visibilitychange', function(e){ if(document.hidden) return; if(site.cmNow()) return;/*CM中はクリックしない*/ if(html.classList.contains('comment')){ core.closeOpenCommentPane(); } }); /* フルスクリーン */ document.addEventListener('fullscreenchange', function(e){ if(!document.fullscreen){ document.fullscreen = true;/*ブラウザサポート待ち*/ elements.fullscreenButton.dataset.icon = 'mini_screen'; }else{ document.fullscreen = false;/*ブラウザサポート待ち*/ elements.fullscreenButton.dataset.icon = 'full_screen'; } core.modify(); setTimeout(core.modify, 2500);/*ダメ押し*/ }); /* ウィンドウリサイズ */ window.addEventListener('resize', function(e){ if(!window.resizing) core.modify(); clearTimeout(window.resizing), window.resizing = setTimeout(function(){ core.modify(); setTimeout(core.modify, 2500);/*ダメ押し*/ window.resizing = null; }, 500); }); /* コメント入力中にcssで表示を制御する */ 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'); }); }, createCanvas: function(){ if(canvas && canvas.isConnected) elements.screen.removeChild(canvas); /* コメントcanvasたちを格納する親 */ 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(){ /* スクリーンサイズを適切に変化させる */ let fullsize = [ (configs.l_overlay === 1), !html.classList.contains('comment'), (configs.l_hide && html.classList.contains('comment') && !html.classList.contains('active')), ].includes(true); let width = (fullsize) ? window.innerWidth : Math.round(window.innerWidth * (1 - configs.l_width / 100)); let height = Math.min(width * (9/16), 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)) / (1 + configs.linemargin)); context.font = `bold ${canvas.fontsize}px ${FONT}`; context.textBaseline = 'alphabetic'; context.fillStyle = 'white'; context.ngFillStyle = 'rgb(255,224,32)';/*独自指定*/ context.strokeStyle = 'black'; context.lineWidth = Math.round(canvas.fontsize * configs.owidth); context.lineJoin = 'round'; canvas.topDelta = ((canvas.fontsize * configs.linemargin) - context.lineWidth - (canvas.fontsize * MARGIN)) / 2;/*canvasのtop計算に使用する*/ }, 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]) !== false){ 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){ ///* 取得したコメントが累計500を超えるとスクロールしなくなるアベマのバグに応急的に対応(1/2) */ //if(elements.board.stalled){ // if(elements.board.children.length === 1) return;/*コメントを閉じた*/ // if(document.hidden) return;/*visibilitychangeに任せる*/ // if(!site.cmNow()){ // elements.board.stalled = false; // return core.closeOpenCommentPane(); // } // let oldest = Date.now(), getTime = (node) => parseInt(node.querySelector('time').dateTime); // for(let i = records.length - 1; records[i]; i--){ // if(records[i].addedNodes.length !== 1 || site.addedNode.comment(records[i].addedNodes[0]) === false) continue; // oldest = getTime(records[i].addedNodes[0]); // break; // } // for(let i = 0; records[i]; i++){ // if(records[i].addedNodes.length !== 1 || site.addedNode.comment(records[i].addedNodes[0]) === false) continue; // if(core.ng.filter(records[i].addedNodes[0])){ // setTimeout(function(){ // core.attachComment(site.get.commentText(records[i].addedNodes[0])); // }, getTime(records[i].addedNodes[0]) - oldest); // } // } // return; //} 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]) !== false): 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]) !== false): /* セレクタを付与しただけで満足 */ break; default: break; } } core.receiveNewComments(elements.newComments); }); break; /* 差し替え単一コメント(newComments内のcommentたちがごっそり新しいNodeに差し替えられてしまうアベマの悲しい仕様) */ case(record.addedNodes.length === 1 && site.addedNode.comment(record.addedNodes[0]) !== false): core.ng.filter(record.addedNodes[0]);/*NGフィルタの再適用*/ replacedComments.push(record.addedNodes[0]); /* 開いていたアベマ公式ブロックフォームを再現する */ if(site.get.commentBlockCancel(record.addedNodes[0])){ animate(function(){ record.addedNodes[0].classList.add('blockform'); }); let observer = observe(record.addedNodes[0], function(records){ if(site.get.commentBlockCancel(record.addedNodes[0])) return; record.addedNodes[0].classList.remove('blockform'); observer.disconnect(); }); } break; /* 差し替えられたNodeの状態を再現する */ case(record.removedNodes.length === 1 && site.removedNode.newComments(record.removedNodes[0]) !== false): /* 開いていた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; default: if(record.addedNodes.length) log(record.addedNodes.length, record.addedNodes, elements.board.innerHTML.substr(0, 500)); break; } } if(ngFormIndex !== null) replacedComments[ngFormIndex].appendChild(elements.ngForm); }); }, receiveNewComments: function(newComments){ let getDelay = function(text){ 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; } }; ///* 取得したコメントが累計500を超えるとスクロールしなくなるアベマのバグに応急的に対応(2/2) */ //const MAXLENGTH = 500, DURATION = 500, BUFFER = (configs.l_hide) ? 100 : 0; //let commentLength = site.get.comments(newComments).length + elements.board.children.length; //log(elements.board.children.length, site.get.comments(newComments).length); //if(MAXLENGTH - BUFFER <= commentLength){ // if(site.cmNow()) elements.board.stalled = true;/*(CM中だとcloseOpenCommentPaneが効かないのでlistenCommentsに託す)*/ // else setTimeout(core.closeOpenCommentPane, DURATION); //} /* コメントの取得間隔を計測する(AINTERVAL仕様の変更に備える) */ let now = Date.now(), commentInterval = (now - parseInt(newComments.dataset.received)) / 1000 || AINTERVAL; newComments.dataset.received = now;/*datasetを使うことでnewCommentsがなくなるときはいっしょになくなる*/ /* コメント表示中に停止してしまう視聴数とコメント数をこのタイミングで更新する */ if(!elements.commentButton.statsUpdated && !site.cmNow()/*CM中は更新しない*/){ elements.commentButton.statsUpdated = true; core.updateStats(); setTimeout(function(){elements.commentButton.statsUpdated = false}, STATSUPDATE); } /* NGコメントをすぐ判定する */ core.ng.expire(); let filteredComments = Array.from(site.get.comments(newComments)).filter(core.ng.filter); /* コメントの再取得で重複コメントが流れるのを回避する(NG判定をすませた後で) */ let latest = parseInt(elements.board.dataset.latest); if(latest) filteredComments = filteredComments.filter(function(comment){ return latest < parseInt(site.get.timeStamp(comment)); }); elements.board.dataset.latest = site.get.latestTimeStamp(newComments); /* バックグラウンドならここで終了 */ if(document.hidden) return; /* スライドダウンアニメーションを上書きする */ core.slideDownNewComments(newComments); /* コメントを流す必要がなければここで終了 */ if(configs.maxlines === 0) return; if(configs.transparency === 100) return; if(configs.maxcomments === 0) return; if(canvas.children.length >= configs.maxcomments) return; /* 投稿経過時間に合わせた自然なばらつきでコメントを流すためのスケジュールを作る */ let schedule = [];/*タイミングだけを格納する配列*/ for(let i = 0; filteredComments[i]; i++){ schedule.push(getDelay(site.get.commentTime(filteredComments[i]))); } 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, comment; comment = filteredComments[i]; i--){ setTimeout(function(){ core.attachComment(site.get.commentText(comment), comment.classList.contains('ng-trial')); }, 1000 * schedule[i]); } }, slideDownNewComments: function(newComments){ const duration = '500ms', easing = 'cubic-bezier(.215,.61,.355,1)';/*アベマ公式の挙動を尊重する*/ newComments.style.maxHeight = newComments.style.minHeight = '0px';/*heightの上書き戦争を避けてmaxHeight/minHeightが使えるのは幸運*/ animate(function(){ let child = newComments.firstElementChild, naturalHeight = getComputedStyle(child).height; newComments.style.transition = 'none'; child.style.transition = 'none'; child.style.transform = `translateY(-${naturalHeight})`; animate(function(){ newComments.style.transition = `max-height ${duration} ${easing}, min-height ${duration} ${easing}`; child.style.transition = `transform ${duration} ${easing}`; animate(function(){ newComments.style.maxHeight = newComments.style.minHeight = naturalHeight; child.style.transform = `translateY(0)`; }); }); }); }, attachComment: function(text, ngTrial = false){ /* 単一スクロールコメントcanvasを用意する */ let scrollComment, c; let width = Math.round(context.measureText(text).width + context.lineWidth); let height = Math.round(canvas.fontsize * (1 + MARGIN) + context.lineWidth); scrollComment = createElement(core.html.scrollComment(width, height)); c = scrollComment.getContext('2d'); c.font = `bold ${canvas.fontsize}px ${FONT}`;/*context.fontを参照したいがSafariでbold指定が文字列として残らないバグ*/ c.textBaseline = context.textBaseline; c.fillStyle = (ngTrial) ? context.ngFillStyle : context.fillStyle; c.strokeStyle = context.strokeStyle; c.lineWidth = context.lineWidth; c.lineJoin = context.lineJoin; let left = Math.round(context.lineWidth/2); let top = Math.round((canvas.fontsize * MARGIN + context.lineWidth)/2 + canvas.fontsize * BASELINE); c.strokeText(text, left, top); c.fillText(text, left, top); /* コメント位置データをまとめる */ 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);/*終了時刻*/ /* 追加されたコメントをどの行に流すかを決定する */ 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(DEBUG) scrollComment.dataset.former = JSON.stringify(lines[i][length - 1]); //if(DEBUG) scrollComment.dataset.self = JSON.stringify(record); lines[i].push(record); scrollComment.style.top = record.top + 'px'; canvas.appendChild(scrollComment); animate(function(){ scrollComment.style.transform = `translateX(-${canvas.width + width}px)`; scrollComment.addEventListener('transitionend', function(e){ canvas.removeChild(scrollComment); lines[i].shift(); }, {once: true}); }); return;/*行に追加したら終了*/ default: continue;/*条件に当てはまらなければforループを回して次の行に入れられるかの判定へ*/ } } }, 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.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){ for(let target = e.target; target.dataset; target = target.parentNode) if(target.dataset.selector === 'comment'){ 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}); return; } }); }, 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 append = function(comment, ngForm){ comment.insertBefore(ngForm, comment.firstElementChild.nextElementSibling);/*公式ブロックフォームが最後尾にある*/ }; 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; append(elements.ngForm.targetComment, elements.ngForm); slideDown(); }, {once: true}); }else{ elements.ngForm.slidingUp = false; append(elements.ngForm.targetComment, elements.ngForm); slideDown(); } }); }; let slideDown = function(){ elements.ngForm.slidingDown = true; if(elements.ngForm.parentNode !== elements.ngForm.targetComment) append(elements.ngForm.targetComment, 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*/ /* input */ 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].original = word.value; 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; } /* textarea */ 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].original = lines[i]; 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.original < b.original); 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.original || 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].original = word.value; 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') || {}; /* 未定義項目をデフォルト値で上書きしていく */ Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT}); }, save: function(new_config){ configs = {};/*CONFIGSに含まれた設定値のみ保存する*/ /* CONFIGSを元に文字列を型評価して値を格納していく */ Object.keys(CONFIGS).forEach((key) => { /* 値がなければデフォルト値 */ if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT; switch(CONFIGS[key].TYPE){ case 'bool': configs[key] = (new_config[key]) ? 1 : 0; break; case 'int': configs[key] = parseInt(new_config[key]); break; case 'float': configs[key] = parseFloat(new_config[key]); break; default: configs[key] = new_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++){ switch(CONFIGS[input.name].TYPE){ case('bool'): new_configs[input.name] = (input.checked) ? 1 : 0; break; case('object'): if(!new_configs[input.name]) new_configs[input.name] = {}; new_configs[input.name][input.value] = (input.checked) ? 1 : 0; break; default: new_configs[input.name] = input.value; break; } } core.config.save(new_configs); core.panel.close('configPanel') /* 新しい設定値で再スタイリング */ core.addStyle(); core.createCanvas();/*modify含む*/ }, 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(['input', 'textarea'].includes(document.activeElement.localName)) return; if(elements[key] && e.key === 'Escape') core.panel.close(key); }); } }, close: function(key){ elements[key].classList.add('hidden'); elements[key].addEventListener('transitionend', function(e){ if(!elements[key]) return; 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 && elements.style.isConnected) document.head.removeChild(elements.style); elements.style = style; }, html: { mini_screen: () => ``, canvasDiv: () => `
`, preCanvas: () => ` `, scrollComment: (width, height) => ` `, ngButton: () => ` `, ngForm: () => `

NGワード登録

`, ngList: () => `

登録NGワード一覧

`, ngHelp: () => `

NGワードについて

登録したワードを含むコメントを削除します。

お試しの場合はハイライト表示されるので、NG対象の確認や、NGとは逆の注目したいキーワードとしても活用できます。24時間の場合は登録時からの期限付きなので、ネタバレや時事ネタなど一時的なNGとしてご活用ください。

コメント一覧のテキスト選択から登録できるほか、NGワード一覧ボタンをクリックして、登録したNGワードを編集したり、複数行での一括登録もできます。

英数字と記号とカタカナは、全角半角や大文字小文字を区別しません。

下記のような正規表現も使えます。

「NGです」を消す登録例:

NG
通常のNGワード
/^NG/
前方一致
/です$/
後方一致
/^NGです$/
完全一致

そのほかの例:

/^.$/
1文字だけのコメント
/.{30}/
30文字以上のコメント
/^[a-z]+$/i
アルファベットだけのコメント
/[0-9]{3}/
3桁以上の数字を含むコメント

`, configButton: () => ` `, configPanel: () => `

${SCRIPTNAME}設定

スクロールコメント

一覧コメント

アベマのナビゲーション

`, panels: () => `
`, style: () => ` `, }, }; class Storage{ static key(key){ return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key; } static save(key, value, expire = null){ key = Storage.key(key); localStorage[key] = JSON.stringify({ expire: expire, value: value, }); } 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[key]; } } let $ = function(s){return document.querySelector(s)}; let $$ = function(s){return document.querySelectorAll(s)}; let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; let observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; let createElement = function(html){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; let getScrollbarWidth = function(){ let div = document.createElement('div'); div.textContent = 'dummy'; document.body.appendChild(div); div.style.overflowY = 'scroll'; let clientWidth = div.clientWidth; div.style.overflowY = 'hidden'; let offsetWidth = div.offsetWidth; document.body.removeChild(div); return offsetWidth - clientWidth; }; let normalize = function(string){ return string.replace(/[!-~]/g, function(s){ return String.fromCharCode(s.charCodeAt(0) - 0xFEE0); }).replace(normalize.RE, function(s){ return normalize.KANA[s]; }).replace(/ /g, ' ').replace(/~/g, '〜'); }; normalize.KANA = { ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ', ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ', ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド', バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ', パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ', ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ', ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ', カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ', サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ', タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト', ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ', ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ', マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ', ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ', ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ', ワ:'ワ', ヲ:'ヲ', ン:'ン', ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ', ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ', "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・', }; normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g'); let log = function(){ if(!DEBUG) return; let l = log.last = log.now || new Date(), n = log.now = new Date(); let stack = new Error().stack, callers = stack.match(/^([^/<]+(?= ' : '') + /* caller */ (callers[1] || '') + '()', ...arguments ); }; core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME); })();