// ==UserScript== // @name SHOWROOM ちょこっとツール // @namespace knoa.jp // @description SHOWROOM をちょこっとだけ使いやすくします。 // @include https://www.showroom-live.com/* // @version 0.1.1 // @grant none // @downloadURL none // ==/UserScript== (function(){ const SCRIPTID = 'ShowroomChocottoTool'; const SCRIPTNAME = 'SHOWROOM ちょこっとツール'; const DEBUG = false;/* [update] 0.1.1 右側に配置したパネルは左辺ではなく右辺に対する位置を記憶する。ほか、軽微な修正。 機能 自分のコメントやギフトをハイライトする 新着のコメントやギフトをハイライトする (新着のコメントやギフトをスムーズスクロールする) ※未実装 配信中のコメントやギフトのログを消さずに全件維持する ページを再読込してもコメントやギフトのログを維持する 終了後、コメントやギフトのログを消さずに残す 終了後、次の配信へ自動遷移しない 右側に配置したパネルは左辺ではなく右辺に対する位置を記憶する 音量調整バーが各パネルの裏に隠れないようにする ギフトログのギフト画像をマウスオーバーで拡大する ほか、各表示レイアウトを最適化する [bug] コメログギフトログのパネル表示を消したくても消せない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 = {}; let replace = function(draggable){ if(positions[draggable.id] === undefined) return; if(parseInt(positions[draggable.id][0]) < parseInt(positions[draggable.id][1])){ draggable.style.left = positions[draggable.id][0]; draggable.style.right = 'auto';/*デフォルト絶対値があるので上書き*/ }else{ draggable.style.left = 'auto';/*デフォルト絶対値があるので上書き*/ draggable.style.right = positions[draggable.id][1]; } }; 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 styles = getComputedStyle(draggable); positions[draggable.id] = [styles.left, styles.right]; Storage.save('positions', positions); }, 250); }, {attributes: true}); }); /* ウィンドウリサイズ時にも再現 */ window.addEventListener('resize', function(e){ clearTimeout(throttles['resize']), throttles['resize'] = setTimeout(function(){ draggables.forEach(draggable => replace(draggable)); }, 250); }); }, 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) => `