Greasy Fork is available in English.

SHOWROOM ちょこっとツール

SHOWROOM をちょこっとだけ使いやすくします。

Pada tanggal 04 Januari 2020. Lihat %(latest_version_link).

// ==UserScript==
// @name        SHOWROOM ちょこっとツール
// @namespace   knoa.jp
// @description SHOWROOM をちょこっとだけ使いやすくします。
// @include     https://www.showroom-live.com/*
// @version     0.1.7
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTID = 'ShowroomChocottoTool';
  const SCRIPTNAME = 'SHOWROOM ちょこっとツール';
  const DEBUG = false;/*
[update] 0.1.7
自分が贈ろうとしているギフトもマウスオーバーで拡大。ほか、バグを修正。

機能
  自分のコメントやギフトをハイライトする
  新着のコメントやギフトをハイライトする
  (新着のコメントやギフトをスムーズスクロールする) ※未実装
  配信中のコメントやギフトのログを消さずに全件維持する
  ページを再読込してもコメントやギフトのログを維持する
  終了後、コメントやギフトのログを消さずに残す
  終了後、次の配信へ自動遷移しない
  ギフト画像をマウスオーバーで拡大する
  音量調整バーが各パネルの裏に隠れないようにする
  右側に配置したパネルは左辺ではなく右辺に対する位置を記憶する
  ほか、各表示レイアウトを最適化する

[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;/*保管コメントを破棄する期限(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'),
      jsInitialData: () => $('#js-initial-data'),
    },
    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 : '',
        };
      },
    },
    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,
            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.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){
      /* 初期状態を記憶 */
      if(logger.icon.classList.contains('active')) logger.icon.dataset.active = 'true';
      else logger.icon.dataset.active = 'false';
      /* クリックするたびに現在の状態を記憶 */
      logger.icon.addEventListener('click', function(e){
        if(logger.icon.dataset.active === 'false') logger.icon.dataset.active = 'true';
        else logger.icon.dataset.active = 'false';
      });
      /* 配信終了時の強制非表示を記憶しておいた状態に応じて回避する */
      observe(logger.panel, function(records){
        if(logger.panel.style.display === 'block') return;
        if(logger.icon.dataset.active === 'true') 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: 480px !important;
            padding: 5px;
            z-index: 100 !important;
          }
          #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;
          }
          /* コメントログ・ギフトログの新着ハイライト */
          #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.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*/
          }
          /* ギフト画像の拡大 */
          #room-gift-item-wrapper{
            padding: 5px;/*padding/marginの入れ替え*/
            margin: 0;
          }
          #gift-log li:hover,
          #room-gift-item-list li:hover{
            z-index: 100;
          }
          #gift-log li,
          #gift-log li .gift-image,
          #room-gift-item-list li,
          #room-gift-item-list li .gift-image{
            overflow: visible;
          }
          #gift-log li .gift-image{
            max-height: 35px;
          }
          #gift-log li .gift-image img,
          #room-gift-item-list li img.gift-image{
            transition: 125ms ease-out;
          }
          #gift-log li .gift-image img:hover,
          #room-gift-item-list li img.gift-image:hover{
            transform: scale(2);
            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);
})();