// ==UserScript== // @name SHOWROOM ちょこっとツール // @namespace knoa.jp // @description SHOWROOM をちょこっとだけ使いやすくします。 // @include https://www.showroom-live.com/* // @version 0.2.0 // @grant none // @downloadURL none // ==/UserScript== (function(){ const SCRIPTID = 'ShowroomChocottoTool'; const SCRIPTNAME = 'SHOWROOM ちょこっとツール'; const DEBUG = false;/* [update] 0.2.0 1秒以上の長押しで自動10連クリック。ギフトに加えてアバターもマウスオーバーで拡大。ほか、軽微な修正。 機能 自分のコメントやギフトをハイライトする 新着のコメントやギフトをハイライトする (新着のコメントやギフトをスムーズスクロールする) ※未実装 配信中のコメントやギフトのログを消さずに全件維持する ページを再読込してもコメントやギフトのログを維持する 終了後、コメントやギフトのログを消さずに残す 終了後、次の配信へ自動遷移しない 1秒以上の長押しでギフトを自動10連クリック アバターやギフト画像をマウスオーバーで拡大する 音量調整バーが各パネルの裏に隠れないようにする 右側に配置したパネルは左辺ではなく右辺に対する位置を記憶する ほか、各表示レイアウトを最適化する [bug] [to do] Before=>After画像 設定パネル ぐりもんテンプレに設定パネル入れとこ 拡張化しないと普及はしない... 頻出NGワードくらいは警告してほしいか [ 'しね', 'いく', 'けばい', 'sex', 'shit', ] [possible] [memo] コメントログは読み込みごとに微妙に順番が前後することがある 読み込み直後にコメントログに1件だけ一瞬現れて消えてしまうバグは報告済み パネルの左端配置を忘れてしまうバグは報告済み => 2019/12/18解消を確認 */ if(window === top && console.time) console.time(SCRIPTID); const SECOND = 1000, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY; const RETRY = 10; const LOGLIMIT = 100;/*公式のログ制限量*/ const RECOVERYLIMIT = 10*MINUTE;/*保管コメントを破棄する期限*/ const LONGPRESS = 1*SECOND;/*ギフト自動連投長押し時間*/ const MOVE = 10;/*マウスズレ判定(px)*/ const INTERVAL = 125;/*ギフト連投間隔*/ const COMBO = 10;/*ギフト連投クリック回数*/ const AVATARPREFIX = 'https://image.showroom-live.com/showroom-prod/image/avatar/';/*アバターURLのPREFIX*/ const GIFTPREFIX = 'https://image.showroom-live.com/showroom-prod/assets/img/gift/';/*ギフトURLのPREFIX*/ let site = { targets: { video: () => $('#js-video'), commentLog: () => $('#comment-log'), commentLogList: () => $('#room-comment-log-list'), giftLog: () => $('#gift-log'), giftLogList: () => $('#gift-log-list'), giftingComboCounter: () => $('#gifting-combo-counter'),/*ギフト連投カウンター*/ roomGiftItemList: () => $('#room-gift-item-list'),/*贈るギフトリスト*/ autoTransision: () => $('#js-onlivelist-auto-transision'),/*カウントダウン*/ onlivelistButton: () => $('#js-onlivelist-btn'),/*オンライブつまみ*/ iconRoomCommentlog: () => $('#icon-room-commentlog'),/*フッタボタン*/ iconRoomGiftlog: () => $('#icon-room-giftlog'),/*フッタボタン*/ draggables: () => $$('.ui-draggable'),/*パネル*/ jsInitialData: () => $('#js-initial-data'),/*JSON*/ }, get: { roomId: () => { let match = location.pathname.match(/^\/([a-z0-9-_]+)/i); return match ? match[1] : undefined; }, myUserName: () => { let data = JSON.parse(elements.jsInitialData.dataset.json); return (data && data.screenId) ? data.screenId : ''; }, commentData: (node) => { let avatar = node.querySelector('.comment-log-avatar img'); let name = node.querySelector('.comment-log-name'); let comment = node.querySelector('.comment-log-comment'); return { avatar: avatar ? avatar.src.replace(AVATARPREFIX, '') : '', name: name ? name.textContent : '', comment: comment ? comment.textContent : '', }; }, giftData: (node) => { let avatar = node.querySelector('.gift-avatar img'); let name = node.querySelector('.gift-user-name'); let image = node.querySelector('.gift-image img'); let num = node.querySelector('.gift-num .num'); return { avatar: avatar ? avatar.src.replace(AVATARPREFIX, '') : '', name: name ? name.textContent : '', image: image ? image.src.replace(GIFTPREFIX, '') : '', num: num ? num.textContent : '', }; }, giftListItem: (img) => { for(let target = img.parentNode; target; target = target.parentNode){ if(target.classList.contains('room-gift-item')) return target; } return img;/*エラー回避*/ }, }, is: { giftImage: (target) => target.classList.contains('gift-image'), onAutoTransition: (autoTransision) => (autoTransision.textContent !== ''), }, }; let html, elements = {}, timers = {}, sizes = {}; let roomId, myUserName; let logStorage = {};/* 'room-id': { lastUpdate: 1234567890, comments: [ {avatar: 'src', name: 'name', comment: 'comment'}, ], gifts: [ {avatar: 'src', name: 'name', image: 'src', num: '1'}, ], } */ let positions = {};/* id: [(leftPx), (rightPx)], */ let core = { initialize: function(){ html = document.documentElement; html.classList.add(SCRIPTID); core.ready(); core.addStyle(); }, ready: function(){ core.getTargets(site.targets, RETRY).then(() => { log("I'm ready."); roomId = site.get.roomId(); myUserName = site.get.myUserName(); core.setupLogStorage(); [ { type: 'comments', panel: elements.commentLog, list: elements.commentLogList, extractData: site.get.commentData, icon: elements.iconRoomCommentlog, html: core.html.comment, }, { type: 'gifts', panel: elements.giftLog, list: elements.giftLogList, extractData: site.get.giftData, icon: elements.iconRoomGiftlog, html: core.html.gift, }, ].forEach(logger => { core.observeLogs(logger); core.keepLogsShown(logger); }); core.longpressToComboClicks(); core.stickDraggablesToEdge(); core.controlAutoTransition(); window.addEventListener('unload', core.save); }); }, setupLogStorage: function(){ let now = Date.now(); logStorage = Storage.read('logStorage') || {}; Object.keys(logStorage).forEach(id => { if(logStorage[id].lastUpdate < now - RECOVERYLIMIT) delete logStorage[id]; }); if(logStorage[roomId] === undefined){ logStorage[roomId] = { lastUpdate: now, comments: [], gifts: [], }; } }, observeLogs: function(logger){ /* 公式バグがあるので内容が安定するのを待つ */ setTimeout(function(){ core.restoreLog(logger); /* 以降、新着とあふれ出てしまうログを扱っていく */ /* 新着1件目, 平常新着, 101件目削除, 配信再開新着, スクロール時の新着表示 が想定シナリオ */ /* 2件同時の時は records[0] が先に挿入されてから records[1] が次に挿入されて最上位となる。 */ let loggingObserver = observe(logger.list, function(records){ let isAddedOnTop = (records.find(r => r.addedNodes[0] === logger.list.firstElementChild) !== undefined); records.forEach(record => { record.addedNodes.forEach(node => {/*新着*/ if(node.dataset.removed === 'true') return;/*無限ループ回避*/ if(isAddedOnTop === true){/*新着1件目, 平常新着*/ log(logger.type, logger.list.children.length); let data = logger.extractData(node); core.markMyItem(data, node); core.feedLogStorage(logger.type, data); }else{/*配信再開新着*/ /* 開きっぱなしのページからの配信再開などでコメントが最後尾に追加されてしまったら最初に挿入し直す */ node.dataset.restarted = 'true'; logger.list.insertBefore(node, logger.list.firstElementChild); } }); record.removedNodes.forEach(node => {/*消されたログ*/ if(node.dataset.restarted === 'true') return;/*無限ループ回避*/ node.dataset.removed = 'true'; logger.list.insertBefore(node, logger.list.children[LOGLIMIT] || null);/*101件目削除*/ }); }); }, {childList: true}); }, 2500);/*けっこう不安定なので余裕を持つ*/ }, restoreLog: function(logger){ /* 読み込みごとに順番が前後することがあるので重複判定などに注意する */ let listedItems = logger.list.children, listedCount = listedItems.length; let storagedData = logStorage[roomId][logger.type], lastIndex = storagedData.length - 1, limitIndex = storagedData.length - LOGLIMIT; storagedData.forEach(data => data.toRestore = true); /* 新着アイテムを古い順に確認して時系列を維持しながらストレージに保存 */ Array.from(listedItems).reverse().forEach(node => { let data = logger.extractData(node); core.markMyItem(data, node); /* ストレージを新しい順に一致するか確認して新着とみなせればストレージ保存 */ for(let i = lastIndex; storagedData[i]; i--){ if(i < limitIndex) break;/*これ以上過去にさかのぼっても一致コメントが見つかる見込みはない*/ if(Object.keys(data).every(key => data[key] === storagedData[i][key])) return storagedData[i].toRestore = false;/*すでに保存済み*/ } core.feedLogStorage(logger.type, data);/*新着コメントとみなせるのでストレージ保存*/ storagedData[storagedData.length - 1].toRestore = false; }); /* 過去ログを回復 */ for(let i = storagedData.length - 1; storagedData[i]; i--){ if(storagedData[i].toRestore === false) continue; let li = createElement(logger.html(storagedData[i])); core.markMyItem(storagedData[i], li); logger.list.append(li); } log(logger.type, 'log restored:', listedCount, '=>', listedItems.length); }, markMyItem: function(data, node){ if(data.name === myUserName) node.dataset.me = 'true'; }, feedLogStorage: function(type, data){ logStorage[roomId][type].push(data); }, keepLogsShown: function(logger){ /* コメントとギフト、パネルとボタンの仕様がちぐはぐで、この組み合わせでしか機能しない */ observe(logger.panel, function(records){ if(logger.panel.style.display === 'block') return;/*表示は歓迎*/ if(logger.icon.clientHeight === 0) logger.panel.style.display = 'block';/*配信終了後の非表示は許さない*/ }, {attributes: true}); }, longpressToComboClicks: function(){ let roomGiftItemList = elements.roomGiftItemList, giftingComboCounter = elements.giftingComboCounter; let timer, longpress = false; let clear = function(e){ clearTimeout(timer); delete(site.get.giftListItem(e.target).dataset.mousedown); }; let getCombo = function(target){ let count = (giftingComboCounter.clientHeight) ? parseInt(giftingComboCounter.textContent) : 0; let timer = setInterval(function(e){ if(count >= COMBO) return clearInterval(timer); target.click(); count++; }, INTERVAL); longpress = true; }; roomGiftItemList.addEventListener('mousedown', function(e){ if(site.is.giftImage(e.target) !== true) return; if(e.buttons !== 1) return;/*プライマリボタンのみ*/ timer = setTimeout(getCombo.bind(null, e.target), LONGPRESS); longpress = false; site.get.giftListItem(e.target).dataset.mousedown = 'true'; roomGiftItemList.addEventListener('mouseout', clear, {once: true}); roomGiftItemList.addEventListener('mouseup', clear, {once: true}); }); roomGiftItemList.addEventListener('click', function(e){ if(e.isTrusted === false) return;/*人間クリックのみ扱う*/ if(longpress === false) return;/*ロングプレスのみ扱う*/ clear(e); e.stopPropagation();/*ロングプレス後にデフォルトのクリックを発生させない*/ }, {capture: true}); }, stickDraggablesToEdge: function(){ /* 右側に配置したパネルは左辺ではなく右辺に対する位置を記憶してほしい */ positions = Storage.read('positions') || {}; let draggables = elements.draggables, throttles = {}, innerWidth = window.innerWidth; let replace = function(draggable){ //log('Replace:', draggable.id, positions[draggable.id]); if(positions[draggable.id] === undefined) return; if(positions[draggable.id][0] < positions[draggable.id][1]){ draggable.style.left = positions[draggable.id][0] + 'px'; draggable.style.right = 'auto';/*デフォルト絶対値があるので上書き*/ }else{ draggable.style.left = 'auto';/*デフォルト絶対値があるので上書き*/ draggable.style.right = positions[draggable.id][1] + 'px'; } }; draggables.forEach(draggable => { /* 独自保存値を再現 */ replace(draggable); /* 位置の変更を保存 */ throttles[draggable.id] = 0; observe(draggable, function(records){ if(draggable.classList.contains('ui-draggable-dragging')) return; if(draggable.classList.contains('ui-resizable-resizing')) return; clearTimeout(throttles[draggable.id]), throttles[draggable.id] = setTimeout(function(){ let rect = draggable.getBoundingClientRect(); if(rect.width === 0 || rect.height === 0) return;/*display:none*/ positions[draggable.id] = [rect.left, innerWidth - rect.right]; Storage.save('positions', positions); //log('Saved:', draggable.id, positions[draggable.id]); }, 125); }, {attributes: true}); }); /* ウィンドウリサイズ時にも再現 */ window.addEventListener('resize', function(e){ clearTimeout(throttles.resize), throttles.resize = setTimeout(function(){ innerWidth = window.innerWidth; draggables.forEach(draggable => replace(draggable)); }, 125); }); }, controlAutoTransition: function(){ let autoTransision = elements.autoTransision, onlivelistButton = elements.onlivelistButton; observe(autoTransision, function(records){ if(site.is.onAutoTransition(autoTransision)) onlivelistButton.click();; }, {attributes: true}); }, save: function(){ logStorage[roomId].lastUpdate = Date.now(); Storage.save('logStorage', logStorage); log('Saved:', logStorage); }, getTargets: function(targets, retry = 0){ const get = function(resolve, reject, retry){ for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){ let selected = targets[key](); if(selected){ if(selected.length) selected.forEach((s) => s.dataset.selector = key); else selected.dataset.selector = key; elements[key] = selected; }else{ if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`)); log(`Not found: ${key}, retrying... (left ${retry})`); return setTimeout(get, 1000, resolve, reject, retry); } } resolve(); }; return new Promise(function(resolve, reject){ get(resolve, reject, retry); }); }, addStyle: function(name = 'style'){ if(core.html[name] === undefined) return; 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: { comment: (comment) => `
  • ${comment.name}
    ${comment.comment}
  • `, gift: (gift) => `
  • ${gift.name}
    x${gift.num}
  • `, style: () => ` `, }, }; const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame; const alert = window.alert, confirm = window.confirm, 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 (SCRIPTID) ? (SCRIPTID + '-' + 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, f){ let target = document.querySelector(s); if(target === null) return null; return f ? f(target) : target; }; const $$ = function(s){return document.querySelectorAll(s)}; const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))}; const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))}; const createElement = function(html = ''){ let outer = document.createElement('div'); outer.innerHTML = html; return outer.firstElementChild; }; const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){ let observer = new MutationObserver(callback.bind(element)); observer.observe(element, options); return observer; }; 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( (SCRIPTID || '') + ':', /* 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 \(chrome-extension:.*?\/userscript.html\?id=/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 4, getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm), }, { name: 'Chrome Extension', detector: /at MARKER \(chrome-extension:/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(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', 0/*line*/, '\n' + new Error().stack); return true; }); const time = function(label){ if(!DEBUG) return; 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 create and 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(SCRIPTID); })();