您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
SHOWROOM をちょこっとだけ使いやすくします。
当前为
// ==UserScript== // @name SHOWROOM ちょこっとツール // @namespace knoa.jp // @description SHOWROOM をちょこっとだけ使いやすくします。 // @include https://www.showroom-live.com/* // @version 0.1.2 // @grant none // ==/UserScript== (function(){ const SCRIPTID = 'ShowroomChocottoTool'; const SCRIPTNAME = 'SHOWROOM ちょこっとツール'; const DEBUG = true;/* [update] 0.1.2 軽微な修正。 Chromeバグ 機能 自分のコメントやギフトをハイライトする 新着のコメントやギフトをハイライトする (新着のコメントやギフトをスムーズスクロールする) ※未実装 配信中のコメントやギフトのログを消さずに全件維持する ページを再読込してもコメントやギフトのログを維持する 終了後、コメントやギフトのログを消さずに残す 終了後、次の配信へ自動遷移しない 右側に配置したパネルは左辺ではなく右辺に対する位置を記憶する 音量調整バーが各パネルの裏に隠れないようにする ギフトログのギフト画像をマウスオーバーで拡大する ほか、各表示レイアウトを最適化する [bug] 右辺記憶Chromeでバグットルやんけ...orz コメログギフトログのパネル表示を消したくても消せないw 通信不調で1秒以上遅延するとダメだな。Observerで工夫できる?XHR監視するしかない? 再読み込みすると重複が発生する。ストレージから重複するのか、リストにだけ重複するのか? ずっとページを放置した上での配信開始時はクリアしたいし、仮にしないにせよログがおかしくなるバグは直さなければならない [to do] Before=>After画像, hover:GIFアニメ 設定パネル ぐりもんテンプレに設定パネル入れとこ 左端バグに対応するか?復帰させるか、1px開けるか? 拡張化しないと普及はしない... 頻出NGワードくらいは警告してほしいか [ 'しね', 'いく', ] [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;/*保管コメントを破棄する期限(ms)*/ 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'), autoTransision: () => $('#js-onlivelist-auto-transision'), onlivelistButton: () => $('#js-onlivelist-btn'), //iconRoomCommentlog: () => $('#icon-room-commentlog'), //iconRoomGiftlog: () => $('#icon-room-giftlog'), draggables: () => $$('.ui-draggable'), }, get: { roomId: () => { let match = location.pathname.match(/^\/([a-z0-9-_]+)/i); return match ? match[1] : undefined; }, myUserName: () => { let name = $('#gift-area .gift-user-name'); return name ? name.textContent : ''; }, 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 : '', }; }, }, is: { 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, html: core.html.comment}, {type: 'gifts', panel: elements.giftLog, list: elements.giftLogList, extractData: site.get.giftData, html: core.html.gift}, ].forEach(logger => { core.observeLogs(logger); core.keepLogsShown(logger); }); 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){ let initialObserver = observe(logger.list, function(records){ initialObserver.disconnect(); /* 公式バグがあるので内容が安定するのを待つ */ setTimeout(function(){ core.restoreLog(logger); /* 以降、新着とあふれ出てしまうログを扱っていく */ let loggingObserver = observe(logger.list, function(records){ log(logger.type, logger.list.children.length); records.forEach(record => { record.addedNodes.forEach(node => { let data = logger.extractData(node); core.markMyItem(data, node); core.feedLogStorage(logger.type, data); }); record.removedNodes.forEach(node => { logger.list.insertBefore(node, logger.list.children[LOGLIMIT] || null); }); }); }, {childList: true}); }, 2500);/*けっこう不安定なので余裕を持つ*/ }, {childList: true}); }, 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 !== 'none') return; if(logger.list.children.length === 0) return; logger.panel.style.display = 'block'; }, {attributes: 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(); 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) => ` <li class="commentlog-row" ${comment.name === myUserName ? 'data-me="true"' : ''}> <div class="comment-log-avatar"><img src="${AVATARPREFIX + comment.avatar}"></div> <div class="comment-log-name">${comment.name}</div> <div class="comment-log-comment">${comment.comment}</div> </li> `, gift: (gift) => ` <li ${gift.name === myUserName ? 'data-me="true"' : ''}> <div class="gift-avatar"><img src="${AVATARPREFIX + gift.avatar}"></div> <div class="gift-user-name">${gift.name}</div> <div class="gift-image"> <img src="${GIFTPREFIX + gift.image}"> <div class="gift-num">x<span class="num">${gift.num}</span></div> </div> </li> `, style: () => ` <style type="text/css"> /* パネル共通 */ .ui-draggable .title{ padding: 0 10px; font-size: 12px; } /* コメント入力欄 */ #js-room-comment-wrapper{ font-size: 14px; line-height: 1.25; height: 40px; width: 460px !important; padding: 5px; z-index: 100 !important; } #js-room-comment #js-chat-input-comment{ font-size: 14px; line-height: 1.25; height: 30px; width: 400px !important; } #js-room-comment .js-room-comment-btn{ height: 30px; } /* コメントログ・ギフトログ・ランキング */ #comment-log #comment-log-content-region, #comment-log #room-comment-log-list, #gift-log #gift-log-list, #ranking #ranking-content-region{ margin: 0; height: calc(100% - 25px/*.title*/ - 5px/*下部ツマミ*/) !important; } #comment-log #room-comment-log-list, #ranking #room-ranking-list{ margin: 0; height: 100% !important; } #comment-log li, #gift-log li, #ranking li{ padding: 2px 5px 2px !important; margin: 0 !important; min-height: 40px !important;/*avatar高さを確保*/ } #comment-log li > .comment-log-avatar{ top: 2px; left: 5px; } #gift-log li > .gift-avatar{ top: 5px;/*重心を考慮*/ left: 5px; } #ranking li > .ranking-num{ top: 2px; left: 5px; } #ranking li > .ranking-avatar{ top: 2px; left: 30px; } #comment-log li > .comment-log-name, #comment-log li > .comment-log-comment, #gift-log li > .gift-user-name, #gift-log li > .gift-image{ margin-left: 45px !important; } #comment-log li > .comment-log-name, #gift-log li > .gift-user-name, #ranking li > .ranking-name{ font-size: 10px; line-height: 1.25; } #comment-log li > .comment-log-comment{ font-size: 14px; line-height: 1.25; } #ranking li > .ranking-sub-info{ margin-top: 0; } /* コメントログ・ギフトログの新着ハイライト */ #comment-log li, #gift-log li{ animation: ${SCRIPTID}-new-highlight 5s linear forwards; } @keyframes ${SCRIPTID}-new-highlight{ 0%{background: rgba(173,228,255,.25)} 100%{background: rgba(173,228,255,.00)} } #comment-log li[data-me="true"], #gift-log li[data-me="true"]{ animation: ${SCRIPTID}-new-highlight-me 5s linear forwards; } @keyframes ${SCRIPTID}-new-highlight-me{ 0%{background: rgba(173,228,255,.50)} 100%{background: rgba(173,228,255,.25)} } /* ギフトログ画像の拡大 */ #gift-log li:hover{ z-index: 100; } #gift-log li, #gift-log li > .gift-image{ overflow: visible; } #gift-log li > .gift-image{ max-height: 35px; } #gift-log li > .gift-image img{ transition: 125ms ease-out; } #gift-log li > .gift-image img:hover{ transform: scale(2); filter: drop-shadow(0 0 4px rgba(0,0,0,1.0)); } /* ギフト */ #gift-area .gift-user-info{ position: absolute; top: 0; right: 0; text-align: right; line-height: 1; z-index: 100; } #gift-area .gift-user-info > li, #gift-area #use-point-mode{ font-size: 10px; } #gift-area .gift-user-level{ margin-right: 1em; } #gift-area #use-point-mode{ right: 10px; text-align: right; } /* イベント */ #event-dialog *{ font-size: 12px !important; text-align: left !important; } #event-dialog .title{ line-height: 1.5; } #event-dialog .event-body{ padding: 5px 10px; } #event-dialog .image{ float: left; width: 80px; margin: 0 10px 5px 0; } #event-dialog .image img{ width: 80px; } #event-dialog .current-rank{ margin-top: 0; } #event-dialog #event-support-wrapper{ clear: both; } #event-dialog .bx-next.showEventDetail, #event-dialog .quest-level-label, #event-dialog .support-header, #event-dialog .support-gauge-wrapper{ display: none !important; } #event-dialog .support-body, #event-dialog .support-goal{ padding-top: 0; margin-top: 0; } /* 音量調整 */ #room-header:hover{ z-index: 101; } #js-room-volume-wrapper{ padding: 15px; margin: 0; } #js-room-volume-wrapper #room-video-volume{ top: 50px; } #js-room-volume-wrapper:hover #room-video-volume{ display: block !important; } /* その他 */ #js-room-section{ overflow: visible; } #dialog-section .twitter-dialog, #dialog-section .gift-alert-dialog{ background: rgba(255,255,255,.875); } #js-room-footer .footer-menu li{ background: rgba(38,50,56,.75); } /* すこすこツール対応 */ #user_live_rank_show{ top: 2px !important; } </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 = '<span></span>'){ 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 \(<anonymous>/, getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1], getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/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); })();