// ==UserScript==
// @name SHOWROOM ちょこっとツール
// @namespace knoa.jp
// @description SHOWROOM をちょこっとだけ使いやすくします。
// @include https://www.showroom-live.com/*
// @version 0.2.1
// @grant none
// ==/UserScript==
(function(){
const SCRIPTID = 'ShowroomChocottoTool';
const SCRIPTNAME = 'SHOWROOM ちょこっとツール';
const DEBUG = false;/*
[update] 0.2.1
長押し自動10連クリックをより速く完了するようにしました。
機能
自分のコメントやギフトをハイライトする
新着のコメントやギフトをハイライトする
(新着のコメントやギフトをスムーズスクロールする) ※未実装
配信中のコメントやギフトのログを消さずに全件維持する
ページを再読込してもコメントやギフトのログを維持する
終了後、コメントやギフトのログを消さずに残す
終了後、次の配信へ自動遷移しない
1秒以上の長押しでギフトを自動10連クリック
アバターやギフト画像をマウスオーバーで拡大する
音量調整バーが各パネルの裏に隠れないようにする
右側に配置したパネルは左辺ではなく右辺に対する位置を記憶する
ほか、各表示レイアウトを最適化する
[bug]
[to do]
Before=>After画像
設定パネル
ぐりもんテンプレに設定パネル入れとこ
拡張化しないと普及はしない...
頻出NGワードくらいは警告してほしいか
[
'しね',
'いく',
'けばい',
'いくぅ',
'どうてい',
'sex',
'shit',
]
[possible]
ゴールド不足を事前に計算してあげる?
ユーザーごとに自分用ニックネーム付与
つや姫、ミルキークイーンなどを把握したい。配信者も呼び方をメモしたい需要はあるだろう。
[memo]
コメントログは読み込みごとに微妙に順番が前後することがある
読み込み直後にコメントログに1件だけ一瞬現れて消えてしまうバグは報告済み
パネルの左端配置を忘れてしまうバグは報告済み => 2019/12/18解消を確認
SHOWROOM API
https://qiita.com/takeru7584/items/f4ba4c31551204279ed2
SHOWROOM WebSocket
https://seesaawiki.jp/shokoro/d/%c4%cc%bf%ae%ca%fd%cb%a1
*/
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 INTERVAL = 25;/*ギフト連投間隔*/
const COMBO = 10;/*ギフト連投クリック回数*/
const WEBSOCKET = 'wss://online.showroom-live.com/';/*WebSocket URL*/
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.listenWebSockets();
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);
});
},
listenWebSockets: function(){
/* 公式の通信内容を取得 */
window.WebSocket = new Proxy(WebSocket, {
construct(target, arguments){
const ws = new target(...arguments);
log(ws, arguments);
if(ws.url.includes(WEBSOCKET)) ws.addEventListener('message', function(e){
let data = e.data.split('\t'), type = data[0], json;
switch(type){
case('MSG'):
json = JSON.parse(data[2]);
switch(parseInt(json.t)){/*型が定まってない件*/
case(1):/*コメント*/
//時刻とユーザーidを付与
//log(json);
break;
case(2):/*ギフト*/
break;
case(5):/*部屋ポイント*/
break;
case(8):/*テロップ*/
break;
default:/*ほかにもいろいろある*/
log('Unknown code:', json.t, json);
break;
}
break;
case('ACK'):
break;
default:
//log('Unknown type:', type, data);
break;
}
});
return ws;
}
});
},
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) => `
<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: 480px !important;
padding: 5px;
}
#js-room-comment #js-chat-input-comment{
font-size: 14px;
line-height: 1.25;
height: 30px;
width: 420px !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;
}
/* 新しいコメント */
#new-comment-button{
z-index: 1000 !important;
}
/* コメントログ・ギフトログの新着ハイライト */
#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-area #gift-area-tabs{
background: rgba(32,42,47,.75);/*#202A2F*/
}
#gift-area #gift-area-tabs .tab-slider-btn{
background: rgba(55,71,79,.75);/*#37474F*/
}
#gift-area #use-point-mode{
background: rgba(93,93,93,.75);/*#5d5d5d*/
}
#gift-area ul#room-gift-item-list li::after{
background: rgba(142,147,154,.6);
border-radius: 3px;
content: " ";
position: absolute;
width: 100%;
height: 100%;
bottom: 0;
z-index: -1;
transform: scaleY(0);
transform-origin: bottom;
transition: transform 0ms;
}
#gift-area ul#room-gift-item-list li[data-mousedown="true"]::after{
transform: scaleY(1);
transition: transform ${LONGPRESS}ms linear;
}
#gift-area ul#room-gift-item-list li[data-mousedown="true"] img.gift-image{
transform: scale(1);
}
#gift-area ul.gift-user-info{
background: rgba(55,71,79,.75);/*#37474F*/
}
#gift-area ul.gift-user-info li.gift-user-show-gold{
background: rgba(31,41,47,.75);/*#37474F*/
}
/* アバターとギフト画像の拡大 */
#comment-log ul#room-comment-log-list,
#gift-log ul#gift-log-list{
padding: 0 0 0 20px !important;
margin: 0 0 0 -20px !important;/*はみ出し*/
}
#gift-area #room-gift-item-wrapper{
padding: 5px 5px 5px 25px !important;/*padding/marginの入れ替え*/
margin: 0 0 0 -20px !important;/*はみ出し*/
overflow-x: hidden !important;
}
#comment-log ul#room-comment-log-list li:hover,
#gift-log ul#gift-log-list li:hover,
#gift-area ul#room-gift-item-list li:hover{
z-index: 100;
}
#comment-log li,
#comment-log li .comment-log-avatar,
#gift-log li,
#gift-log li .gift-avatar,
#gift-log li .gift-image,
#room-gift-item-list li,
#room-gift-item-list li .gift-image{
overflow: visible !important;
}
#gift-log li .gift-image{
max-height: 35px;
}
#comment-log li .comment-log-avatar img,
#gift-log li .gift-avatar img,
#gift-log li .gift-image img,
#room-gift-item-list li img.gift-image{
transition: 125ms ease-out;
}
#comment-log li .comment-log-avatar img:hover,
#gift-log li .gift-avatar img:hover,
#gift-log li .gift-image img:hover,
#room-gift-item-list li a:hover img.gift-image{
transform: scale(2);
filter: drop-shadow(0 0 4px rgba(0,0,0,1.0));
}
#room-gift-item-list li .gift-gold{
position: relative;
}
#room-gift-item-list li a:hover + .gift-gold{
filter: drop-shadow(0 0 4px rgba(0,0,0,1.0));
}
/* イベント */
#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-footer:hover{
z-index: 100;
}
#js-room-footer .footer-menu li{
transition: filter 125ms ease-out;
}
#js-room-footer:hover .footer-menu li{
filter: drop-shadow(0 0 5px rgba(38,50,56,1));
}
/* その他 */
#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);
})();