// ==UserScript== // @name AbemaTV Screen Comment Scroller // @namespace knoa.jp // @description AbemaTV のコメントをニコニコ風にスクロールさせます。 // @include https://abema.tv/* // @version 2.15.3 // @grant none // @downloadURL https://update.greasyfork.icu/scripts/30531/AbemaTV%20Screen%20Comment%20Scroller.user.js // @updateURL https://update.greasyfork.icu/scripts/30531/AbemaTV%20Screen%20Comment%20Scroller.meta.js // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'ScreenCommentScroller'; const DEBUG = false;/* [update] 2.15.3 映像の背景色を黒(#000000)に。Chromeでのフルスクリーンバグを解消。 [bug] [i]のときコメと視聴数 [to do] windowフォーカス時にコメント欄フォーカスならハイライトしてあげる マウスオーバー時の透過率設定 Command+ホイールでコメント透明度はどうじゃろ? [1]-[0]で透明度はやるか マウスオーバー時の透過率変化に対応する? タイムシフトでのコメント対応(プレミアム限定?) ビデオでもナビゲーションの透明化対応? [i]の透明度は映像にコメ一覧を重ねない場合もっと濃くてもいい。重ねる場合もかな? VideoAssistantとの統合? NG設定ラベル [お試し] => [注目] [to research] 投稿直後に出てしまう新着コメントボタンは1秒後に閉じてもいいよね ブロックやNGでのコメント一覧スクロール位置 コメントが大量で一斉に流れてしまうときに、空白時間にもコメをばらけさせる? まれにコメント取得できずにローディングが続くバグが(どこかでループしてる?) 気軽な「阿久津」「あっくん」のハイライトができるUXを 自分の投稿コメ+隣接1コメが一覧最上部に居残り続ける問題。 タブ切り替えた瞬間に緑コメとして現れる。 タブ切り替えで一瞬現れる。削除対象から漏れてずっと放置されている風。 visibilitychangeの処理を再検証か。 500と関係ある...? display:noneか何かでお茶を濁せないか [possible] NGワードの最終ヒット日時の記録(viaニコニコ) ユーザーブロックアイコン(秒数と差し替わる)のアニメーション ピクチャインピクチャはアベマが公式にやるべきだろうけど、やらないままブラウザが任意要素に対応したら実装しようか [requests] 設定のナビゲーションに「マウスを近づけたら表示する」 設定のスクロールコメントに「画面下部の専用領域に流す」「高さ(%)」 設定の一覧コメントに「コメントをひとつずつ表示する」 一覧コメントの横幅「%」以外も指定可能に [not to do] Safari音量ボタンうわずってる件 4:3対応(左右黒帯の 16:9 video。videoの内容はDRM制限で取得できない番組あり。サムネイルの内容はcrossoriginで取得できない) */ if(window === top && console.time) console.time(SCRIPTNAME); const CONFIGS = { /* スクロールコメント */ maxlines: {TYPE: 'int', DEFAULT: 10},/*最大行数(文字サイズ連動)*/ linemargin: {TYPE: 'float', DEFAULT: 0.20},/*行間(比率)*/ transparency: {TYPE: 'float', DEFAULT: 75.0},/*透明度(%)*/ owidth: {TYPE: 'float', DEFAULT: 0.10},/*縁取りの太さ(比率)*/ duration: {TYPE: 'float', DEFAULT: 5.00},/*横断にかける秒数*/ maxcomments: {TYPE: 'int', DEFAULT: 50},/*最大同時表示数*/ font: {TYPE: 'string', DEFAULT: '' },/*フォント指定*/ /* 一覧コメント */ l_hide: {TYPE: 'bool', DEFAULT: 0 },/*操作していない時は画面外に隠す*/ l_overlay: {TYPE: 'bool', DEFAULT: 0 },/*透過して映像に重ねて配置する*/ lc_transparency: {TYPE: 'float', DEFAULT: 0.0},/*文字の透明度(%)*/ lb_transparency: {TYPE: 'float', DEFAULT: 50.0},/*背景の透明度(%)*/ l_width: {TYPE: 'float', DEFAULT: 16.5},/*横幅(%)*/ lc_maxlines: {TYPE: 'int', DEFAULT: 30},/*最大行数(文字サイズ連動)*/ lc_linemargin: {TYPE: 'float', DEFAULT: 0.50},/*改行されたコメントの行間(比率)*/ lc_margin: {TYPE: 'float', DEFAULT: 1.50},/*コメント同士の間隔(比率)*/ l_showtime: {TYPE: 'bool', DEFAULT: 1 },/*投稿時刻を表示する*/ /* アベマのナビゲーション */ n_clickonly: {TYPE: 'bool', DEFAULT: 0 },/*画面クリック時のみ表示する*/ n_delay: {TYPE: 'float', DEFAULT: 4.00},/*隠れるまでの時間(秒)*/ n_transparency: {TYPE: 'float', DEFAULT: 50.0},/*透明度(%)*/ }; const AINTERVAL = 7000;/*AbemaTVのコメント取得間隔の基本値(ms)*/ const STATSUPDATE = 60*1000;/*視聴数とコメント数を更新する間隔(ms)*/ const FONT = 'Arial, sans-serif';/*スクロールフォント*/ const BASELINE = 85/100;/*フォントのbaseline比率*/ const MARGIN = 2/10;/*通常のフォントサイズを飛び出す xgÅ(永◆∬∫√ ̄ などの文字を確実に収めるための余裕(比率)*/ const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/ /* サイト定義 */ let retry = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/ let site = { targets: [ /* 構造 */ function header(){let header = $('body > div > div > header'); return (header) ? site.use(header) : false;}, function footer(){let tvFooter = $('.com-tv-TVFooter'); return (tvFooter) ? site.use(tvFooter.parentNode) : false;}, function board(){let board = $('[class*="OnReachTop"]'); return (board) ? site.use(board) : false;}, function parent(){let parent = $('main > div > div > div'); return (parent) ? site.use(parent) : false;}, function screen(){let videoContainer = $('.com-a-Video__container'); return (videoContainer) ? site.use(videoContainer.parentNode.parentNode.parentNode) : false;}, /* ペイン */ function commentPane(){let form = $('form:not([role="search"])'); return (form) ? site.use(form.parentNode.parentNode) : false;}, function channelPane(){let container = $('[class*="-tv-VChannelList__container"]'); return (container) ? site.use(container.parentNode) : false;}, function programPane(){let container = $('[class*="-tv-VChannelList__container"]'); return (container) ? site.use(container.parentNode.nextElementSibling) : false;}, /* ボタン */ function controller(){let controller = $('.com-tv-TVController'); return (controller) ? site.use(controller) : 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 programButton = $('.com-tv-TVFooter__footer-left'); return (programButton) ? site.use(programButton) : 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 enquete(){let container = $('[class*="-tv-VChannelList__container"]'); return (container) ? site.use(container.parentNode.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 commentFormTextarea(){let textarea = $('form:not([role="search"]) textarea'); return (textarea) ? site.use(textarea) : false;}, function viewCounter(){let viewCounter = $('.com-tv-TVViewCounter'); return (viewCounter) ? site.use(viewCounter) : false;}, function programName(){let programName = $('.com-m-TextIcon--dark'); return (programName) ? site.use(programName) : false;}, function fullscreenElement(){let videoContainer = $('.com-a-Video__container'); return (videoContainer) ? site.use(videoContainer.parentNode) : false;}, ], addedNode: { newCommentsButton: function(node){let button = node.parentNode.querySelector('[data-selector="commentPane"] > div > button'); return (button) ? site.use(node, 'newCommentsButton') : false;}, comment: function(node){let time = node.querySelector('time'); return (time) ? site.use(node, 'comment') : false;}, }, timeshiftTargets: [ /* 構造 */ function header(){let header = $('body > div > div > header'); return (header) ? site.use(header) : false;}, function board(){let board = $('.c-archive-comment-ArchiveCommentContainerView__list-wrapper'); return (board) ? site.use(board) : false;}, function parent(){let parent = $('.c-tv-TimeshiftSlotContainerView-player'); return (parent) ? site.use(parent) : false;}, function screen(){let screen = $('.c-tv-SlotPlayerContainer-screen'); return (screen) ? site.use(screen) : false;}, /* ペイン */ function commentPane(){let pane = $('.c-tv-SlotPlayerContainer__comment-wrapper'); return (pane) ? site.use(pane) : false;}, /* ボタン */ function commentButton(){let button = $('.c-tv-SlotPlayerContainer-comment-button button'); return (button) ? site.use(button) : false;}, ], timeshiftAddedNode: { commentContainer: function(node){let commentContainer = node.parentNode.querySelector('.com-a-OnReachTop'); return (commentContainer) ? site.use(commentContainer, 'commentContainer') : false;}, comment: function(node){let message = node.querySelector(':scope > div > p.com-archive-comment-ArchiveCommentItem__message'); return (message) ? site.use(node, 'comment') : false;}, }, reactPropertyName: '', get: { reactPropertyName: function(node){return site.reactPropertyName = Object.keys(node).find((key) => key.includes('reactInternalInstance'));}, commentIsOwner: function(comment){return comment[site.reactPropertyName].return.memoizedProps.comment._isOwner;}, commentText: function(comment){return comment.firstElementChild.firstElementChild.textContent;}, commentTime: function(comment){return comment.querySelector('time').dateTime;}, commentBlock: function(comment){return comment.querySelector('button[title="ブロック"]');}, commentBlockCancel: function(comment){return comment.nextElementSibling.querySelector('form button');}, viewCount: function(viewCounter){return viewCounter.querySelector('[data-selector="viewCounter"] > span');}, commentCount: function(commentButton){return commentButton.querySelector('[data-selector="commentButton"] > span');}, closer: function(){ /* チャンネル切り替えごとに差し替わるのでつど取得 */ let button = $('[data-selector="screen"] > div > div > button'); return button ? button : log(`Not found: closer`); }, programId: function(){ /* アベマの仕様に依存しまくり */ if(!window.dataLayer) return log('No dataLayer.'); for(let i = window.dataLayer.length - 1; window.dataLayer[i]; i--){ if(window.dataLayer[i].programId) return window.dataLayer[i].programId; } }, thumbImgApi: function(programId, number){ /* アベマの仕様に依存しまくり */ const API = 'https://hayabusa.io/abema/programs/{programId}/thumb00{number}.q95.w135.h76.jpg'; return API.replace('{programId}', site.get.programId).replace('{number}', number); }, statsApi: function(){ /* アベマの仕様に依存しまくり */ if(!window.dataLayer) return log('No dataLayer.'); 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); } }, }, isCmNow: 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 = {}, timers = {}; let canvas, preContext, lines = [];/*アニメーション関連は極力浅いオブジェクトに*/ let core = { initialize: function(){ html = document.documentElement; core.config.read(); core.ng.initialize(); core.listenUserActions(); core.checkUrl(); core.addStyle('initialStyle'); }, checkUrl: function(){ let previousUrl = ''; setInterval(function(){ if(location.href === previousUrl) return;/*URLが変わってない*/ switch(true){ case(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(); } break; // case(location.href.startsWith('https://abema.tv/channels/')):/*見逃し視聴ページ(の可能性)*/ // core.readyForTimeshift(); // break; default:/*視聴ページではない*/ core.gone(); break; } 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... (left ${retry})`); return retry-- && setTimeout(core.ready, 1000); } } elements.closer = site.get.closer(); site.reactPropertyName = site.get.reactPropertyName(elements.board); log("I'm Ready."); /* すべての要素が出揃っていたので */ html.classList.add(SCRIPTNAME); core.observeAspectRatio(); core.setupFullscreenButton(); core.createCanvas(); core.listenUserActionsOnCommentPane(); core.listenComments(); core.panel.createPanels(); core.ng.createButton(); core.ng.listenSelection(); core.config.createButton(); core.addStyle(); core.observeCommentButton(); }, readyForTimeshift: function(){ /* 必要な要素が出揃うまで粘る */ for(let i = 0, target; target = site.timeshiftTargets[i]; i++){ if(target() === false){ if(!retry) return log(`Not found: ${target.name}, I give up.`); log(`Not found: ${target.name}, retrying... (left ${retry})`); return retry-- && setTimeout(core.readyForTimeshift, 1000); } } log("I'm Ready for Timeshift."); /* すべての要素が出揃っていたので */ html.classList.add(SCRIPTNAME); core.observeAspectRatio(); core.createCanvas(); core.listenCommentsOnTimeshift(); core.panel.createPanels(); // core.ng.createButton(); // core.ng.listenSelection(); // core.config.createButton(); core.addStyle('timeshiftStyle'); // core.observeCommentButton(); }, gone: function(){ setTimeout(function(){ if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style); html.classList.remove(SCRIPTNAME); html.classList.remove('comment'); clearInterval(timers.ratio); }, 1000); }, 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(); }, observeAspectRatio: function(){ const INTERVAL = 1000, RATIOS = {'16:9': (16/9), '4:3': (4/3)}, DEFAULTRATIO = RATIOS['16:9'], THUMBLIMIT = 2/*サムネイルの最大確認数*/; const round = function(ratio){/*いちばん近いratioを見つけるだけ*/ return RATIOS[Object.keys(RATIOS).sort((a, b) => Math.abs(RATIOS[a] - ratio) - Math.abs(RATIOS[b] - ratio))[0]]; }; const modify = function(ratio){ if(ratio === configs.aspectRatio) return; configs.aspectRatio = ratio || RATIOS['16:9']; screen.dataset.estimatedAspectRatio = ratio; core.modify(); }; const getActiveVideo = function(){ let videos = screen.querySelectorAll('video[src]'); for(let i = 0; videos[i]; i++){ if(videos[i].paused) continue; if(!videos[i].videoWidth || !videos[i].videoHeight) continue; return videos[i]; } }; const checkThumbRatio = function(programId, number = 1){ let img = document.createElement('img'); img.src = site.get.thumbImgApi(programId, number); img.addEventListener('load', function(e){ let ratio = round(img.naturalWidth / img.naturalHeight); if(ratio !== DEFAULTRATIO) return modify(cache[programId] = ratio); if(number < THUMBLIMIT) checkThumbRatio(programId, ++number); }); }; let status = {}, previousStatus = {}, cache = {}, screen = elements.screen; configs.aspectRatio = screen.dataset.estimatedAspectRatio = parseFloat(screen.dataset.estimatedAspectRatio) || DEFAULTRATIO; clearInterval(timers.ratio), timers.ratio = setInterval(function(){ /* アクティブなビデオ要素を見つける */ let video = getActiveVideo(); if(!video) return; status = { width: video.videoWidth, height: video.videoHeight, ratio: round(video.videoWidth / video.videoHeight), src: video.src, }; if(status.src === previousStatus.src) return; /* srcが変わった = タテヨコ比が変わったかもしれない */ let programId = site.get.programId(); if(!programId) return;/*ページ読み込み直後はprogramIdが取得できない*/ if(site.isCmNow()) modify(status.ratio);/*CM中ならvideo要素のタテヨコ比(status.ratio)だけが手がかり*/ else{ if(cache[programId]){ modify(cache[programId]); screen.dataset.videoAspectRatio = cache[programId]; }else{ modify(cache[programId] = status.ratio);/*いったんビデオのタテヨコ比を採用しつつ*/ screen.dataset.videoAspectRatio = status.ratio; //例外があるのでサムネイルを参考にするのは危険(ビデオが完全な16:9なのにサムネイルが4:3、サムネが黒メイン、などのパターンがある) //if(status.ratio === DEFAULTRATIO) checkThumbRatio(programId, 1);/*番組サムネイルで4:3の可能性を追確認する*/ } } previousStatus = status; }, INTERVAL); }, 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); let viewCount = site.get.viewCount(elements.viewCounter), commentCount = site.get.commentCount(elements.commentButton); if(viewCount) viewCount.textContent = formatNumber(xhr.response.stats.view); if(commentCount) commentCount.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('keydown', function(e){ switch(e.key){ /*テキスト入力中の上下キーによるチャンネル移動を防ぐ*/ case('ArrowUp'): case('ArrowDown'): if(['input', 'textarea'].includes(e.target.localName)){ e.stopPropagation(); } break; } }, true); 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'; /* スクロールをなめらかにする */ const DURATION = '500ms', EASING = 'ease'; let board = elements.board, child = elements.board.firstElementChild; let scrollTop = board.scrollTop, scrollHeight = board.scrollHeight, clientHeight = board.clientHeight; if(scrollTop !== scrollHeight - clientHeight){/*最下端(最新コメント位置)じゃなければ*/ child.style.transition = `transform ${DURATION} ${EASING}`; animate(function(){ child.style.transform = `translateY(-${scrollHeight - scrollTop - clientHeight}px)` child.addEventListener('transitionend', function(e){ child.style.transition = 'none'; child.style.transform = 'translateY(0)'; board.scrollTop = scrollHeight - clientHeight;/*これをクリックの代わりとする*/ }, {once: true}); }); }else{ elements.newCommentsButton.click(); } e.stopPropagation(); }else{ /* デフォルトのボタン動作が実行される */ } return; case(elements.fullscreenButton): if(elements.fullscreenElement.requestCanceled === undefined){/*公式のフルスクリーン要求をキャンセル*/ elements.fullscreenElement.requestFullscreen = () => {}; elements.fullscreenElement.requestCanceled = true; } if(document.fullscreenElement === null) document.body.requestFullscreen();/*bodyごとフルスクリーン化する*/ 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(); if(elements.commentPane.classList.contains('active')) return;/*コメントフォームからフォーカスを外すだけ*/ html.classList.add('click');/*250msのtransition遅延をなくしてからキビキビactivate*/ html.classList.toggle('active'); elements.header.addEventListener('transitionend', function(e){ html.classList.remove('click'); }, {once: true}); if(configs.l_hide && !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); document.addEventListener('visibilitychange', function(e){ if(document.hidden) return; /* 番組開始のタイミングを挟んだバックグラウンドからの復帰でコメント取得が停止する現象を防ぐ */ if(site.isCmNow()) return;/*CM中はクリックしない*/ if(html.classList.contains('comment')) core.closeOpenCommentPane(); }); document.addEventListener('fullscreenchange', function(e){ if(document.fullscreenElement){/*フルスクリーンなら*/ elements.fullscreenButton.dataset.icon = 'mini_screen'; }else{ elements.fullscreenButton.dataset.icon = 'full_screen'; } setTimeout(core.modify, 500);/*初動*/ setTimeout(core.modify, 2500);/*ダメ押し*/ }); let resizing, resize = function(){ core.modify(); }; window.addEventListener('resize', function(e){ if(!resizing) setTimeout(resize, 500);/*初動*/ clearTimeout(resizing), resizing = setTimeout(function(){ resize();/*ダメ押し*/ resizing = null; }, 2500); }); }, listenUserActionsOnCommentPane: function(){ elements.board.addEventListener('click', function(e){/*アベマ公式ブロックを「コメントクリックでトグル」に差し替える*/ if(!e.isTrusted) return; let comment; /* コメントのクリックを判定する */ for(let target = e.target; target; target = target.parentNode){ switch(true){ case(target === elements.board): return; /* コメント */ case(target.dataset.selector === 'comment'): comment = target; break; /* アベマ公式ブロックフォーム */ case(target.name && target.name.startsWith('comment-report-form')): comment = target.previousElementSibling; break; } if(comment) break; } if(!comment) return; /* コメントをクリックしたようなので */ if(!comment.dataset.blockform){/*ブロックフォームはまだ開かれていない*/ /* フォーム用のクラスは常にひとつだけ */ let last = comment.parentNode.lastBlockFormComment; if(last) delete last.dataset.blockform; comment.parentNode.lastBlockFormComment = comment; let observer = observe(comment, function(records){ if(site.get.commentBlockCancel(comment)) return; delete comment.dataset.blockform; observer.disconnect(); }); /* ブロックフォームを開く */ site.get.commentBlock(comment).click(); comment.style.transition = `background 250ms ${EASING}`; comment.dataset.blockform = 'true';/*classはアベマ公式に上書きされる*/ }else{/*ブロックフォームはすでに開かれている*/ /* ブロックフォームを閉じる */ delete comment.dataset.blockform; comment.addEventListener('transitionend', function(e){ comment.style.transition = 'none'; site.get.commentBlockCancel(comment).click(); }, {once: true}); } }); elements.commentForm.addEventListener('click', function(e){/*コメント欄のフォーカスを維持する*/ elements.commentFormTextarea.focus(); }); elements.commentForm.addEventListener('focusin', function(e){/* コメント入力中にcssで表示を制御する */ if(e.target.form && e.target.form.dataset.selector === 'commentForm') elements.commentPane.dataset.active = 'true';/*classはアベマ公式に上書きされる*/ }); elements.commentForm.addEventListener('focusout', function(e){ setTimeout(function(){ if(document.activeElement === elements.commentFormTextarea) return; if(e.target.form && e.target.form.dataset.selector === 'commentForm') delete elements.commentPane.dataset.active; }, 250);/*コメント欄のフォーカスを維持するなら、commentPane.dataset.activeを一瞬でも失いたくないので*/ }); }, createCanvas: function(){ if(canvas && canvas.isConnected) elements.screen.removeChild(canvas); /* コメントcanvasたちを格納する親 */ canvas = createElement(core.html.canvasDiv()); /* テキストサイズ計測に使用 */ elements.preCanvas = createElement(core.html.preCanvas()); preContext = elements.preCanvas.getContext('2d', {alpha: false}); elements.screen.insertBefore(canvas, elements.screen.firstElementChild); core.modify(); }, modify: function(){ if(!elements.screen || !canvas) return; /* 新着コメント受信状態を保つ */ let newCommentsButton = elements.newCommentsButton, board = elements.board; if(!newCommentsButton || !newCommentsButton.isConnected) board.scrollTop = (board.scrollHeight - board.clientHeight); /* スクリーンサイズを適切に変化させる */ let beFullsize = [ (configs.l_overlay === 1), !html.classList.contains('comment'), (configs.l_hide && html.classList.contains('comment') && !html.classList.contains('active')), ].includes(true); let fonts = (configs.font === '') ? FONT : `${configs.font}, ${FONT}`; let width = (beFullsize) ? elements.parent.clientWidth : Math.round(elements.parent.clientWidth * (1 - configs.l_width / 100)); let height = atMost(width / configs.aspectRatio, elements.parent.clientHeight); /* 余裕があるなら configs.l_width ずらす */ if(configs.l_overlay && height * configs.aspectRatio + width * (configs.l_width / 100) < width) elements.screen.dataset.shift = 'true'; else elements.screen.dataset.shift = 'false'; 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));/*独自指定*/ preContext.font = `bold ${canvas.fontsize}px ${fonts}`; preContext.textBaseline = 'alphabetic'; preContext.fillStyle = 'white'; preContext.fillStyles = {isOwner: 'rgb(81,195,0)', ngTrial: 'rgb(255,224,32)'};/*独自指定*/ preContext.strokeStyle = 'black'; preContext.lineWidth = Math.round(canvas.fontsize * configs.owidth); preContext.lineJoin = 'round'; canvas.topDelta = ((canvas.fontsize * configs.linemargin) - preContext.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(){ newCommentsButton.classList.add('shown'); }, {once: true}); }else{ newCommentsButton.classList.add('shown'); } } }); observe(elements.board.firstElementChild, function(records){ let newComments = []; for(let i = 0, record; record = records[i]; i++){ /* 新着コメント */ if(record.addedNodes.length === 1 && site.addedNode.comment(record.addedNodes[0]) !== false){ newComments.push(record.addedNodes[0]);/*古い順に格納される*/ if(site.get.commentIsOwner(record.addedNodes[0])) record.addedNodes[0].dataset.isOwner = 'true';/*自分の投稿*/ } } if(newComments.length) core.receiveNewComments(newComments); }); }, listenCommentsOnTimeshift: function(){ observe(elements.board, function(records){ if(records[0].addedNodes.length === 1 && site.timeshiftAddedNode.commentContainer(records[0].addedNodes[0]) !== false){ let ul = records[0].addedNodes[0].firstElementChild; Array.from(ul.children).forEach(li => site.timeshiftAddedNode.comment(li)); observe(records[0].addedNodes[0].firstElementChild, function(records){ let newComments = []; for(let i = 0, record; record = records[i]; i++){ /* 新着コメント */ if(record.addedNodes.length === 1 && site.timeshiftAddedNode.comment(record.addedNodes[0]) !== false){ newComments.push(record.addedNodes[0]);/*古い順に格納される*/ } } if(newComments.length) core.receiveNewCommentsOnTimeshift(newComments); }); } }); }, receiveNewComments: function(newComments){ /* コメント表示中に停止してしまう視聴数とコメント数をこのタイミングで更新する */ if(!elements.commentButton.statsUpdated && !site.isCmNow()/*CM中は更新しない*/){ elements.commentButton.statsUpdated = true; core.updateStats(); setTimeout(function(){elements.commentButton.statsUpdated = false}, STATSUPDATE); } /* NGコメントをすぐ判定する */ core.ng.expire(); let filteredComments = 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.commentTime(comment)); }); latest = elements.board.dataset.latest = parseInt(site.get.commentTime(newComments[newComments.length - 1])); /* バックグラウンドならここで終了 */ if(document.hidden) return; /* スライドアップアニメーションを上書きする */ core.slideUpNewComments(filteredComments); /* コメントを流す必要がなければここで終了 */ if(configs.maxlines === 0) return; if(configs.transparency === 100) return; if(configs.maxcomments === 0) return; /* 配列先頭の古いコメントから順に流す */ let earliest = atLeast(latest - AINTERVAL, parseInt(site.get.commentTime(filteredComments[0]))); for(let i = 0, comment; comment = filteredComments[i]; i++){ setTimeout(function(){ if(configs.maxcomments <= canvas.children.length) return; core.attachComment(site.get.commentText(comment), comment.dataset); }, parseInt(site.get.commentTime(comment)) - earliest); } }, receiveNewCommentsOnTimeshift: function(newComments){ /* NGコメントをすぐ判定する */ core.ng.expire(); let filteredComments = newComments.filter(core.ng.filter); /* バックグラウンドならここで終了 */ if(document.hidden) return; /* スライドアップアニメーションを上書きする */ core.slideUpNewComments(filteredComments); /* コメントを流す必要がなければここで終了 */ if(configs.maxlines === 0) return; if(configs.transparency === 100) return; if(configs.maxcomments === 0) return; /* 配列先頭の古いコメントから順に流す */ let schedule = [];/*時刻情報がないのでランダムにばらけさせる*/ for(let i = 0, length = filteredComments.length; filteredComments[i]; i++){ schedule[i] = i * (1 / ((length - 1) || 1)) * Math.random() * AINTERVAL; } schedule.sort((a, b) => a - b); for(let i = 0, comment; comment = filteredComments[i]; i++){ setTimeout(function(){ if(configs.maxcomments <= canvas.children.length) return; core.attachComment(site.get.commentText(comment), comment.dataset); }, schedule[i]); } }, slideUpNewComments: function(newComments){ const DURATION = '500ms', EASING = 'ease', HIGHLIGHT = 5000; let board = elements.board, child = elements.board.firstElementChild, height = 0; let scrollHeight = board.scrollHeight, clientHeight = board.clientHeight;/*大量コメント時にscrollHeightの負荷が高いがやむを得ない*/ for(let i = 0, comment; comment = newComments[i]; i++){ height += parseFloat(getComputedStyle(comment).height);/*大量コメント時に少し負荷があるがやむを得ない*/ comment.dataset.new = 'true'; } //log(scrollHeight, clientHeight, height, board.scrollTop); board.scrollTop = (scrollHeight - clientHeight) - 2;/* 本来は1でよいが、ブラウザのズーム倍率に対する保険 */ child.style.transform = `translateY(${height - 2}px)`; animate(function(){ child.style.transition = `transform ${DURATION} ${EASING}`; child.style.transform = `translateY(0)`; child.addEventListener('transitionend', function(e){ child.style.transition = 'none'; animate(function(){board.scrollTop = (scrollHeight - clientHeight) + 1}); }, {once: true}); }); setTimeout(function(){ for(let i = 0, comment; comment = newComments[i]; i++){ delete comment.dataset.new; } }, HIGHLIGHT); }, attachComment: function(text, dataset){ /* 単一スクロールコメントcanvasを用意する */ let scrollComment, c, fonts = (configs.font === '') ? FONT : `${configs.font}, ${FONT}`; let width = Math.round(preContext.measureText(text).width + preContext.lineWidth); let height = Math.round(canvas.fontsize * (1 + MARGIN) + preContext.lineWidth); scrollComment = createElement(core.html.scrollComment(width, height)); c = scrollComment.getContext('2d'); c.font = `bold ${canvas.fontsize}px ${fonts}`;/*preContext.fontを参照したいがSafariでbold指定が文字列として残らないバグ*/ c.textBaseline = preContext.textBaseline; switch(true){ case(dataset.isOwner === 'true'): c.fillStyle = preContext.fillStyles.isOwner; break; case(dataset.ngTrial === 'true'): c.fillStyle = preContext.fillStyles.ngTrial; break; default: c.fillStyle = preContext.fillStyle; break; } c.strokeStyle = preContext.strokeStyle; c.lineWidth = preContext.lineWidth; c.lineJoin = preContext.lineJoin; let left = Math.round(preContext.lineWidth/2); let top = Math.round((canvas.fontsize * MARGIN + preContext.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(); }, listenSelection: function(){ /* コメント上でmousedownした状態からのmousemove,mouseupでのみselect() */ let select = function(e){ let selection = window.getSelection(), selected = selection.toString(); let comment = (selection.anchorNode.length) ? 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; }; elements.board.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 && elements.ngButton.isConnected) return; /* フルスクリーンボタンを元にNG一覧ボタンを追加する */ elements.ngButton = createElement(core.html.ngButton()); elements.ngButton.className = elements.fullscreenButton.className; elements.ngButton.addEventListener('click', core.ng.toggleListPanel); elements.fullscreenButton.parentNode.insertBefore(elements.ngButton, elements.fullscreenButton);/*元のDOM位置関係にできるだけ影響を与えない*/ }, createForm: function(comment){ elements.ngForm = createElement(core.html.ngForm()); elements.ngForm.querySelector('button.list').addEventListener('click', core.ng.toggleListPanel); elements.ngForm.querySelector('button.help').addEventListener('click', core.ng.toggleHelpPanel); 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.ngListPanel) 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.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(); }, openListPanel: function(){ core.panel.open(elements.ngListPanel || core.ng.createListPanel()); }, closeListPanel: function(){ core.panel.close(elements.ngListPanel); }, toggleListPanel: function(){ core.panel.toggle(elements.ngListPanel || core.ng.createListPanel(), core.ng.openListPanel, core.ng.closeListPanel); }, createListPanel: function(){ let ngListPanel = elements.ngListPanel = createElement(core.html.ngListPanel()); ngListPanel.querySelector('button.help').addEventListener('click', core.ng.toggleHelpPanel); ngListPanel.querySelector('button.cancel').addEventListener('click', core.ng.closeListPanel); ngListPanel.querySelector('button.save').addEventListener('click', function(e){ core.ng.save(core.ng.getNewNgwords().filter((ngword) => (ngword.type !== 'remove'))); core.ng.closeListPanel(); }); ngListPanel.querySelector('ul > li.add > p.words > textarea').addEventListener('keypress', function(e){ animate(function(){ let checked = ngListPanel.querySelector('ul > li.add > p.type input:checked'); if(e.target.value === '') return checked && (checked.checked = false); if(!checked) ngListPanel.querySelector('ul > li.add > p.type input[value="forever"]').checked = true; }); }, true); /* 並べ替え */ configs.ng_sort = configs.ng_sort || {key: 'date', reverse: false}; ngListPanel.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(); ngListPanel.keyAssigns = { 'Escape': core.ng.closeListPanel, }; return ngListPanel; }, getNewNgwords: function(){ let new_ngwords = Array.from(ngwords);/*clone*/ /* input */ let lis = elements.ngListPanel.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.ngListPanel.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.ngListPanel.querySelector('p.sort').classList.add('disabled'); }else{ elements.ngListPanel.querySelector('p.sort').classList.remove('disabled'); let sort = elements.ngListPanel.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.ngListPanel.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); } }, showHelpPanel: function(){ core.panel.show(elements.ngHelpPanel || core.ng.createHelpPanel()); }, hideHelpPanel: function(){ core.panel.hide(elements.ngHelpPanel); }, toggleHelpPanel: function(){ core.panel.toggle(elements.ngHelpPanel || core.ng.createHelpPanel(), core.ng.showHelpPanel, core.ng.hideHelpPanel); }, createHelpPanel: function(){ let ngHelpPanel = elements.ngHelpPanel = createElement(core.html.ngHelpPanel()); ngHelpPanel.querySelector('button.ok').addEventListener('click', core.ng.hideHelpPanel); ngHelpPanel.keyAssigns = { 'Escape': core.ng.hideHelpPanel, }; return ngHelpPanel; }, 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.dataset.ngDeleted = 'true'; return false; } break; case('trial'): if(match(comment, ngword)){ comment.dataset.ngTrial = 'true'; 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 && elements.configButton.isConnected) return; /* フルスクリーンボタンを元に設定ボタンを追加する */ let configButton = elements.configButton = createElement(core.html.configButton()); configButton.className = elements.fullscreenButton.className; configButton.addEventListener('click', core.config.toggle); elements.fullscreenButton.parentNode.insertBefore(configButton, elements.fullscreenButton);/*元のDOM位置関係にできるだけ影響を与えない*/ }, open: function(){ core.panel.open(elements.configPanel || core.config.createPanel()); }, close: function(){ core.panel.close(elements.configPanel); }, toggle: function(){ core.panel.toggle(elements.configPanel || core.config.createPanel(), core.config.open, core.config.close); }, createPanel: function(){ let configPanel = elements.configPanel = createElement(core.html.configPanel()); configPanel.querySelector('button.cancel').addEventListener('click', core.config.close); configPanel.querySelector('button.save').addEventListener('click', function(e){ let inputs = 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.config.close(); /* 新しい設定値で再スタイリング */ core.addStyle(); core.observeAspectRatio();/*タテヨコ比の判定を含む*/ core.createCanvas();/*modify含む*/ }, true); configPanel.querySelector('input[name="l_overlay"]').addEventListener('click', function(e){ let lc_transparency = configPanel.querySelector('input[name="lc_transparency"]'); let lb_transparency = configPanel.querySelector('input[name="lb_transparency"]'); lc_transparency.disabled = !lc_transparency.disabled; lb_transparency.disabled = !lb_transparency.disabled; lc_transparency.parentNode.parentNode.classList.toggle('disabled'); lb_transparency.parentNode.parentNode.classList.toggle('disabled'); }, true); configPanel.keyAssigns = { 'Escape': core.config.close, }; return configPanel; }, }, panel: { createPanels: function(){ if(elements.panels) return; let panels = elements.panels = createElement(core.html.panels()); panels.dataset.panels = 0; document.body.appendChild(panels); /* Escapeキーで閉じるなど */ window.addEventListener('keydown', function(e){ if(['input', 'textarea'].includes(document.activeElement.localName)) return; Array.from(panels.children).forEach((p) => { if(p.classList.contains('hidden')) return; /* 表示中のパネルに対するキーアサインを確認 */ if(p.keyAssigns){ if(p.keyAssigns[e.key]){ e.preventDefault(); return p.keyAssigns[e.key]();/*単一キーなら簡単に処理*/ } for(let i = 0, assigns = Object.keys(p.keyAssigns); assigns[i]; i++){ let keys = assigns[i].split('+');/*プラス区切りで指定*/ if(!['altKey','shiftKey','ctrlKey','metaKey'].every( (m) => (e[m] && keys.includes(m)) || (!e[m] && !keys.includes(m))) ) return;/*修飾キーの一致を確認*/ if(keys[keys.length - 1] === e.key){ e.preventDefault(); return p.keyAssigns[assigns[i]]();/*最後が通常キー*/ } } } }); }, true); }, open: function(panel){ let panels = elements.panels; if(!panel.isConnected){ panel.classList.add('hidden'); panels.insertBefore(panel, Array.from(panels.children).find((p) => panel.dataset.order < p.dataset.order)); } panels.dataset.panels = parseInt(panels.dataset.panels) + 1; animate(function(){panel.classList.remove('hidden')}); }, show: function(panel){ core.panel.open(panel); }, hide: function(panel, close = false){ if(panel.classList.contains('hidden')) return;/*連続Escなどによる二重起動を避ける*/ let panels = elements.panels; panel.classList.add('hidden'); panel.addEventListener('transitionend', function(e){ panels.dataset.panels = parseInt(panels.dataset.panels) - 1; if(close){ panels.removeChild(panel); elements[panel.dataset.name] = null; } }, {once: true}); }, close: function(panel){ core.panel.hide(panel, true); }, toggle: function(panel, open, close){ if(!panel.isConnected || panel.classList.contains('hidden')) open(); else close(); }, }, addStyle: function(name = 'style'){ let style = createElement(core.html[name]()); document.head.appendChild(style); if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]); elements[name] = style; }, html: { mini_screen: () => ``, canvasDiv: () => `
`, preCanvas: () => ` `, scrollComment: (width, height) => ` `, ngButton: () => ` `, ngForm: () => `

NGワード登録

`, ngListPanel: () => `

登録NGワード一覧

`, ngHelpPanel: () => `

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: () => `
`, initialStyle: () => ` `, style: () => ` `, timeshiftStyle: () => ` `, }, }; const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame; const getComputedStyle = window.getComputedStyle, fetch = window.fetch; if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}}); class Storage{ static key(key){ return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key; } static save(key, value, expire = null){ key = Storage.key(key); localStorage[key] = JSON.stringify({ value: value, saved: Date.now(), expire: expire, }); } 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.removeItem(key); } static saved(key){ key = Storage.key(key); if(localStorage[key] === undefined) return undefined; let data = JSON.parse(localStorage[key]); if(data.saved) return data.saved; else return undefined; } } const $ = function(s){return document.querySelector(s)}; const $$ = function(s){return document.querySelectorAll(s)}; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; const createElement = function(html = ''){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; const 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; }; const 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'); const atLeast = function(min, b){ return Math.max(min, b); }; const atMost = function(a, max){ return Math.min(a, max); }; const between = function(min, b, max){ return Math.min(Math.max(min, b), max); }; const log = function(){ if(!DEBUG) return; let l = log.last = log.now || new Date(), n = log.now = new Date(); let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error); //console.log(error.stack); console.log( SCRIPTNAME + ':', /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3), /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's', /* :00 */ ':' + line, /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') + /* caller */ (callers[1] || '') + '()', ...arguments ); }; log.formats = [{ name: 'Firefox Scratchpad', detector: /MARKER@Scratchpad/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Console', detector: /MARKER@debugger/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 3', detector: /\/gm_scripts\//, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1], getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Greasemonkey 4+', detector: /MARKER@user-script:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Firefox Tampermonkey', detector: /MARKER@moz-extension:/, getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6, getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm), }, { name: 'Chrome Console', detector: /at MARKER \(/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \()/gm), }, { name: 'Chrome Tampermonkey', detector: /at MARKER \((userscript\.html|chrome-extension:)/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 6, getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm), }, { name: 'Edge Console', detector: /at MARKER \(eval/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm), }, { name: 'Edge Tampermonkey', detector: /at MARKER \(Function/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4, getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm), }, { name: 'Safari', detector: /^MARKER$/m, getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/ getCallers: (e) => e.stack.split('\n'), }, { name: 'Default', detector: /./, getLine: (e) => 0, getCallers: (e) => [], }]; log.format = log.formats.find(function MARKER(f){ if(!f.detector.test(new Error().stack)) return false; //console.log('////', f.name, 'wants', 85, '\n' + new Error().stack); return true; }); const time = function(label){ const BAR = '|', TOTAL = 100; switch(true){ case(label === undefined):/* time() to output total */ let total = 0; Object.keys(time.records).forEach((label) => total += time.records[label].total); Object.keys(time.records).forEach((label) => { console.log( BAR.repeat((time.records[label].total / total) * TOTAL), label + ':', (time.records[label].total).toFixed(3) + 'ms', '(' + time.records[label].count + ')', ); }); time.records = {}; break; case(!time.records[label]):/* time('label') to start the record */ time.records[label] = {count: 0, from: performance.now(), total: 0}; break; case(time.records[label].from === null):/* time('label') to re-start the lap */ time.records[label].from = performance.now(); break; case(0 < time.records[label].from):/* time('label') to add lap time to the record */ time.records[label].total += performance.now() - time.records[label].from; time.records[label].from = null; time.records[label].count += 1; break; } }; time.records = {}; core.initialize(); if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME); })();