Greasy Fork

Greasy Fork is available in English.

AbemaTV Shortcut Key Controller

AbemaTVでショートカットキーによる操作を可能にします。キー割り当てはYouTube準拠。

当前为 2019-01-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        AbemaTV Shortcut Key Controller
// @namespace   knoa.jp
// @description AbemaTVでショートカットキーによる操作を可能にします。キー割り当てはYouTube準拠。
// @include     https://abema.tv/*
// @version     2.5.1
// @grant       none
// ==/UserScript==

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ShortcutKeyController';
  const DEBUG = false;/*
  [update] 2.5.1
  ビデオで[x]キーでも「次のエピソード」を閉じる。
  
  [bug]

  [to do]
  音量、内部では小数点で保持するようにすればヌルヌルでも0.1刻みとかできるかも

  [possible]
  ビデオの停止中にrewind/advancesで時刻表記が変化しないのはアベマ公式のバグ。
  ビデオの停止中にrewind/advancesが1度しか効かないのはアベマ公式のバグ。
  Keyboardで音量、スクロール文字の透明度?

  [requests]

  [not to do]
  Edge: ホイール音量調整できない。(MouseEvent未サポート)
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const DUMMY = document.createElement('span');
  const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/
  let site = {
    elements: {
      /* 共通 */
      fullscreenButton: function(){let node = $('use[*|href*="mini_screen.svg"]') || $('use[*|href*="_screen.svg"]')/*ビデオのbuttonにaria-labelがないので*/; return node ? node.parentNode.parentNode : DUMMY;},
      volumeSlider: function(){let node = $('button[aria-label^="音声"]'); return node ? node.previousSibling.firstElementChild.firstElementChild : DUMMY;},
      muteButton: function(){let node = $('button[aria-label^="音声"]'); return node ? node : DUMMY;},
      /* リアルタイム */
      channelButton: function(){let node = $('button[aria-label="放送中の裏番組"]'); return node ? node : DUMMY;},
      timetableButton: function(){let node = $('button[data-selector="TimetableViewerButton"]'); return node ? node : DUMMY;},
      commentButton: function(){let node = $('use[*|href^="/images/icons/comment.svg"]'); return node ? node.parentNode.parentNode : DUMMY;},
      programButton: function(){let node = $('button[aria-label^="フルスクリーン"]'); return (node) ? node.parentNode.lastElementChild.firstElementChild.firstElementChild : DUMMY;},
      commentTextarea: function(){let node = $('textarea[placeholder="コメントを入力"]'); return node ? node : DUMMY;},
      header: function(){let node = $('body > div > div > header'); return (node) ? node : DUMMY;},
      footer: function(){let node = $('button[aria-label^="フルスクリーン"]'); return node ? node.parentNode.parentNode : DUMMY;},
      closer: function(){
        /* チャンネル切り替えごとに変わる */
        let loading = $('img[src="/images/misc/feed_loading.gif"]');
        if(!loading) return;
        let button = loading.parentNode.parentNode.parentNode.parentNode.querySelectorAll('div > button')[1];/*アベマの構造にすごく依存する*/
        return button;
      },
      timetableHeaders: function(){let nodes = $$('#TimetableViewer-timetable-panel .channels > li > header'); return nodes ? nodes : DUMMY;},
      /* ビデオ */
      stopper: function(){let node = $('.com-vod-VODScreen-video-cover'); return node ? node : DUMMY;},
      playButton: function(){let node = $('.com-vod-VideoControlBar__play-handle'); return node ? node : DUMMY;},
      rewindButton: function(){let node = $('.com-vod-VideoControlBar__rewind-10'); return node ? node : DUMMY;},
      advancesButton: function(){let node = $('.com-vod-VideoControlBar__advances-30'); return node ? node : DUMMY;},
      nextCloseButton: function(){let node = $('.com-vod-VODScreen-next-program-close-button'); return node ? node : DUMMY;},
    },
    isCommentPaneHidden: function(){
      let form = $('form:not([role="search"])');
      return (form) ? (form.parentNode.parentNode.getAttribute('aria-hidden') === 'true') : false;
    },
    getCurrentVolume: function(){
      let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect();
      if(slider.dataset.volume === undefined){
        return Math.round(100 * parseInt(slider.firstElementChild.style.height) / rect.height);
      }else{
        return parseInt(slider.dataset.volume);
      }
    },
    /* 整数による0-100の音量調整に対応する */
    modifyVolume: function(e){
      let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect(), volume = site.getCurrentVolume();
      switch(e.deltaMode){
        case(WheelEvent.DOM_DELTA_PIXEL):/*ヌルヌル*/
          switch(true){
            case(e.deltaY < -20): volume -= e.deltaY/20; break;
            case(e.deltaY <  -1): volume += 1;           break;/*最低1*/
            case(e.deltaY <   0): volume += 0;           break;/*微量なら0*/
            case(e.deltaY <  +1): volume -= 0;           break;/*微量なら0*/
            case(e.deltaY < +20): volume -= 1;           break;/*最低1*/
            default:              volume -= e.deltaY/20; break;
          }
          break;
        case(WheelEvent.DOM_DELTA_LINE):/*カクカク*/
        default:
          if(e.deltaY < 0){
            switch(true){
              case(volume < 10): volume +=  1; break;/* 1単位*/
              default:           volume +=  5; break;/* 5単位*/
            }
          }else{
            switch(true){
              case(volume <= 10): volume -=  1; break;/* 1単位*/
              default:            volume -=  5; break;/* 5単位*/
            }
          }
          break;
          `111`.toString();
      }
      volume = between(0, Math.round(volume), 100);/*四捨五入して0-100に収める*/
      let options = {
        clientX: rect.x + (rect.width/2),
        clientY: rect.y + (rect.height * (1 - volume/100)) + .5/*ゼロの時は確実にゼロにする*/,
        bubbles: true,
      };
      slider.dispatchEvent(new MouseEvent('mousedown'/*clickだと効かない*/, options));
      slider.dispatchEvent(new MouseEvent('mouseup', options));
      slider.dataset.volume = volume;
      core.indicateVolume(volume);
    },
    assign: function(e){
      switch(true){
        case(location.href.startsWith('https://abema.tv/now-on-air/')):
          return core.realtime(e);
        case(location.href.startsWith('https://abema.tv/channels/')):
        case(location.href.startsWith('https://abema.tv/video/watch/')):
        case(location.href.startsWith('https://abema.tv/video/episode/')):
          return core.video(e);
      }
    },
  };
  let globals = {}, elements = {}, configs = {}, indicatorTimer;
  let core = {
    initialize: function(){
      window.addEventListener('wheel', site.assign, true);
      window.addEventListener('keydown', site.assign, true);
      core.appendIndicator();
      core.panel.createPanels();
      core.addStyle();
    },
    appendIndicator: function(e){
      elements.indicator = createElement(core.html.indicator());
      document.body.appendChild(elements.indicator);
    },
    indicateVolume: function(volume){
      let indicator = elements.indicator;
      while(indicator.firstChild) indicator.removeChild(indicator.firstChild);
      indicator.textContent = volume;
      indicator.classList.add('active');
      clearTimeout(indicatorTimer);
      indicatorTimer = setTimeout(function(){
        indicator.classList.remove('active');
      }, 1000);
    },
    indicateRewind: function(){
      let indicator = elements.indicator;
      while(indicator.firstChild) indicator.removeChild(indicator.firstChild);
      indicator.appendChild(createElement(core.html.rewind()));
      indicator.classList.add('active');
      clearTimeout(indicatorTimer);
      indicatorTimer = setTimeout(function(){
        indicator.classList.remove('active');
      }, 20*1000);
    },
    realtime: function(e){
      switch(true){
        /* 音量 */
        case(e.type === 'wheel' && Math.abs(e.deltaX) < Math.abs(e.deltaY)/*縦ホイールのみ*/):
          /* あらゆる場所でのイベントを拾ってwindow.addEventListenerで一括処理する代償をここで支払う */
          let parents = [site.elements.closer(), site.elements.header(), site.elements.footer(), ...site.elements.timetableHeaders()];
          for(let target = e.target; target; target = target.parentNode){
            if(parents.includes(target)){
              site.modifyVolume(e);
              return e.preventDefault();
            }
          }
          return;
        /* コメント入力欄フォーカスを外す */
        case(e.key === 'Escape'):
          if(document.activeElement === site.elements.commentTextarea()){
            document.activeElement.blur();
            return e.preventDefault();
          }
          /* Screen Comment Scroller でペインを開いていれば閉じてあげる */
          if(document.documentElement.classList.contains('channel')) document.documentElement.classList.remove('channel');
          if(document.documentElement.classList.contains('program')) document.documentElement.classList.remove('program');
          return;
        /* 以下、テキスト入力中は反応しない */
        case(['input', 'textarea'].includes(document.activeElement.localName)):
          return;
        /* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
        case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
          return;
        /* コメント入力欄フォーカス */
        case(e.key === 'k'):
        case(e.key === ' '):
        case(e.key === 'Enter'):
          /* コメント欄が表示されていなければあらかじめ表示しておく */
          if(site.isCommentPaneHidden()) site.elements.commentButton().click();
          site.elements.commentTextarea().focus();
          return e.preventDefault();
        /* コメント */
        case(e.key === 'c'):
          if(site.isCommentPaneHidden()) site.elements.commentButton().click();
          else site.elements.closer().click();
          return e.preventDefault();
        /* 裏番組一覧 */
        case(e.key === 'n'):
          site.elements.channelButton().click();
          return e.preventDefault();
        /* 番組表 */
        case(e.key === 't'):
          site.elements.timetableButton().click();
          return e.preventDefault();
        /* 番組情報 */
        case(e.key === 'i'):
          site.elements.programButton().click();
          return e.preventDefault();
        /* 10秒戻る(20秒かけて追いつく) */
        case(e.key === 'j'):
        case(e.key === 'ArrowLeft'):
          const REWIND = 10, CATCHUP = 1.5;
          let videos = document.querySelectorAll('video[src]');
          for(let i = 0, video; video = videos[i]; i++){
            if(video.paused || video.rewinded) continue;
            if(video.currentTime > 1e9) continue;/*currentTimeがunixtimeならmpeg-dashで巻き戻し不可*/
            let rewind = atMost(video.currentTime, REWIND);
            video.rewinded = true;
            video.currentTime = video.currentTime - rewind;
            video.playbackRate = CATCHUP;
            setTimeout(function(){
              video.rewinded = false;
              video.playbackRate = 1;
            }, (rewind / (CATCHUP - 1))*1000);
          }
          core.indicateRewind();
          return e.preventDefault();
        /* フルスクリーン */
        case(e.key === 'f'):
          site.elements.fullscreenButton().click();
          return e.preventDefault();
        /* ミュート */
        case(e.key === 'm'):
          site.elements.muteButton().click();
          if(site.getCurrentVolume() === 0) core.indicateVolume((elements.indicator.textContent === 'mute') ? 0 : 'mute');
          else core.indicateVolume(site.getCurrentVolume());
          return e.preventDefault();
        /* ヘルプ */
        case(e.key === 'h'):
        case(e.key === '/'):
          core.help.toggle('realtime');
          return e.preventDefault();
      }
    },
    video: function(e){
      switch(true){
        /* 音量 */
        case(e.type === 'wheel' && Math.abs(e.deltaX) < Math.abs(e.deltaY)/*縦ホイールのみ*/):
          if(e.target === site.elements.stopper()){
            site.modifyVolume(e);
            return e.preventDefault();
          }
          return;
        /* 以下、テキスト入力中は反応しない */
        case(['input', 'textarea'].includes(document.activeElement.localName)):
          return;
        /* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
        case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
          return;
        /* 次のエピソードへの移動ボタンを閉じる */
        case(e.key === 'Escape'):
        case(e.key === 'x'):
          let nextCloseButton = site.elements.nextCloseButton();
          if(nextCloseButton){
            nextCloseButton.click();
            e.stopPropagation();
            return e.preventDefault();
          }
        /* 再生・停止トグル */
        case(e.key === 'k'):
        case(e.key === ' '):
        case(e.key === 'Enter'):
          site.elements.playButton().click();
          return e.preventDefault();
        /* 10秒戻る */
        case(e.key === 'j'):
        case(e.key === 'ArrowLeft'):
          site.elements.rewindButton().click();
          return e.preventDefault();
        /* 30秒進む */
        case(e.key === 'l'):
        case(e.key === 'ArrowRight'):
          site.elements.advancesButton().click();
          return e.preventDefault();
        /* フルスクリーン */
        case(e.key === 'f'):
          site.elements.fullscreenButton().click();
          return e.preventDefault();
        /* ミュート */
        case(e.key === 'm'):
          site.elements.muteButton().click();
          if(site.getCurrentVolume() === 0) core.indicateVolume((elements.indicator.textContent === 'mute') ? 0 : 'mute');
          else core.indicateVolume(site.getCurrentVolume());
          return e.preventDefault();
        /* ヘルプ */
        case(e.key === 'h'):
        case(e.key === '/'):
          core.help.toggle('video');
          return e.preventDefault();
      }
    },
    help: {
      open: function(type){
        core.panel.open(elements.helpPanel || core.help.createPanel(type));
      },
      close: function(){
        core.panel.close(elements.helpPanel);
      },
      toggle: function(type){
        core.panel.toggle(elements.helpPanel || core.help.createPanel(type), core.help.open.bind(null, type), core.help.close);
      },
      createPanel: function(type){
        let helpPanel = elements.helpPanel = createElement(core.html.helpPanel(type));
        helpPanel.querySelector('button.ok').addEventListener('click', core.help.close);
        helpPanel.keyAssigns = {
          'Escape': core.help.close,
        };
        return helpPanel;
      },
    },
    panel: {
      createPanels: function(){
        if(elements.panels) return;
        let panels = elements.panels = createElement(core.html.panels());
        panels.dataset.panels = 0;
        document.body.appendChild(panels);
        /* Escapeキーで閉じるなど */
        window.addEventListener('keydown', function(e){
          if(['input', 'textarea'].includes(document.activeElement.localName)) return;
          Array.from(panels.children).forEach((p) => {
            if(p.classList.contains('hidden')) return;
            /* 表示中のパネルに対するキーアサインを確認 */
            if(p.keyAssigns){
              if(p.keyAssigns[e.key]) return p.keyAssigns[e.key]();/*単一キーなら簡単に処理*/
              for(let i = 0, assigns = Object.keys(p.keyAssigns); assigns[i]; i++){
                let keys = assigns[i].split('+');/*プラス区切りで指定*/
                if(!['altKey','shiftKey','ctrlKey','metaKey'].every(
                  (m) => (e[m] && keys.includes(m)) || (!e[m] && !keys.includes(m)))
                ) return;/*修飾キーの一致を確認*/
                if(keys[keys.length - 1] === e.key) return p.keyAssigns[assigns[i]]();/*最後が通常キー*/
              }
            }
          });
        }, true);
      },
      open: function(panel){
        let panels = elements.panels;
        if(!panel.isConnected){
          panel.classList.add('hidden');
          panels.insertBefore(panel, Array.from(panels.children).find((p) => panel.dataset.order < p.dataset.order));
        }
        panels.dataset.panels = parseInt(panels.dataset.panels) + 1;
        animate(function(){panel.classList.remove('hidden')});
      },
      show: function(panel){
        core.panel.open(panel);
      },
      hide: function(panel, close = false){
        if(panel.classList.contains('hidden')) return;/*連続Escなどによる二重起動を避ける*/
        let panels = elements.panels;
        panel.classList.add('hidden');
        panel.addEventListener('transitionend', function(e){
          panels.dataset.panels = parseInt(panels.dataset.panels) - 1;
          if(close){
            panels.removeChild(panel);
            elements[panel.dataset.name] = null;
          }
        }, {once: true});
      },
      close: function(panel){
        core.panel.hide(panel, true);
      },
      toggle: function(panel, open, close){
        if(!panel.isConnected || panel.classList.contains('hidden')) open();
        else close();
      },
    },
    addStyle: function(name = 'style'){
      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: {
      indicator: (type) => `
        <div id="${SCRIPTNAME}-indicator"></div>
      `,
      rewind: (type) => `
        <svg id="rewind" height="25" width="25"><use xlink:href="/images/icons/rewind_10.svg?v=v18.1219.0#svg-body"></use></svg>
      `,
      helpPanel: (type) => `
        <div class="${SCRIPTNAME} panel" id="${SCRIPTNAME}-helpPanel" data-name="helpPanel" data-order="1">
          <h1>${SCRIPTNAME} ヘルプ</h1>
          <h2>共通:</h2>
          <dl>
            <dt><kbd>[H]</kbd><kbd>[/]</kbd></dt><dd>ヘルプ表示 ([H]elp)</dd>
            <dt><kbd>[F]</kbd></dt><dd>フルスクリーン ([F]ullscreen)</dd>
            <dt><kbd>[M]</kbd></dt><dd>ミュート ([M]ute)</dd>
            <dt><kbd>マウスホイール</kbd></dt><dd>音量調整</dd>
          </dl>
          <h2${(type === 'realtime') ? '' : ' class="disabled"'}>リアルタイム放送中:</h2>
          <dl>
            <dt><kbd>[K]</kbd><kbd>[ ]</kbd><kbd>[⏎]</kbd></dt><dd>コメント入力欄フォーカス</dd>
            <dt><kbd>[Esc]</kbd></dt><dd>コメント入力欄フォーカスを外す</dd>
            <dt><kbd>[C]</kbd></dt><dd>コメント表示 ([C]omment)</dd>
            <dt><kbd>[N]</kbd></dt><dd>裏番組一覧 ([N]ow on air)</dd>
            <dt${(site.elements.timetableButton().isConnected) ? '' : ' class="disabled"'}><kbd>[T]</kbd></dt><dd>番組表 ([T]imetable)</dd>
            <dt><kbd>[I]</kbd></dt><dd>番組情報 ([I]nformation)</dd>
            <dt><kbd>[J]</kbd><kbd>[←]</kbd></dt><dd>10秒戻る(20秒かけて追いつく)<sup>※</sup></dd>
            <dd class="note">※現在のところ、SPECIAL, GOLD, ドラマ, アニメ, みんなのアニメ の各系列チャンネルでは効きません。</dd>
          </dl>
          <h2${(type === 'video') ? '' : ' class="disabled"'}>ビデオ再生中:</h2>
          <dl>
            <dt><kbd>[K]</kbd><kbd>[ ]</kbd><kbd>[⏎]</kbd></dt><dd>再生・停止</dd>
            <dt><kbd>[J]</kbd><kbd>[←]</kbd></dt><dd>10秒戻る</dd>
            <dt><kbd>[L]</kbd><kbd>[→]</kbd></dt><dd>30秒進む</dd>
            <dt><kbd>[Esc]</kbd><kbd>[X]</kbd></dt><dd>「次のエピソード」を閉じる<sup>※</sup></dd>
            <dd class="note">※[Esc]はフルスクリーン解除が優先されます。</dd>
          </dl>
          <p class="buttons"><button class="ok primary">OK</button></p>
        </div>
      `,
      panels: () => `
        <div class="panels" id="${SCRIPTNAME}-panels"></div>
      `,
      style: () => `
        <style type="text/css">
          /* panel_zIndex:           ${configs.panel_zIndex           = 100} */
          /* nav_transition:         ${configs.nav_transition         = `250ms ${EASING}`} */
          /* パネル共通 */
          #${SCRIPTNAME}-panels{
            position: absolute;
            width: 100%;
            height: 100%;
            top: 0;
            left: 0;
            overflow: hidden;
            pointer-events: none;
          }
          #${SCRIPTNAME}-panels div.panel{
            position: absolute;
            max-height: 100%;/*小さなウィンドウに対応*/
            overflow: auto;
            left: 50%;
            bottom: 50%;
            transform: translate(-50%, 50%);
            z-index: ${configs.panel_zIndex};
            background: rgba(0,0,0,.75);
            transition: ${configs.nav_transition};
            padding: 5px 0;
            pointer-events: auto;
          }
          #${SCRIPTNAME}-panels div.panel.hidden{
            bottom: 0;
            transform: translate(-50%, 100%) !important;
          }
          #${SCRIPTNAME}-panels h1,
          #${SCRIPTNAME}-panels h2,
          #${SCRIPTNAME}-panels h3,
          #${SCRIPTNAME}-panels h4,
          #${SCRIPTNAME}-panels legend,
          #${SCRIPTNAME}-panels ul,
          #${SCRIPTNAME}-panels ol,
          #${SCRIPTNAME}-panels dl,
          #${SCRIPTNAME}-panels code,
          #${SCRIPTNAME}-panels p{
            color: rgba(255,255,255,1);
            font-size: 14px;
            padding: 2px 10px;
            line-height: 1.4;
          }
          #${SCRIPTNAME}-panels header{
            display: flex;
          }
          #${SCRIPTNAME}-panels header h1{
            flex: 1;
          }
          #${SCRIPTNAME}-panels div.panel > p.buttons{
            text-align: right;
            padding: 5px 10px;
          }
          #${SCRIPTNAME}-panels div.panel > p.buttons button{
            width: 120px;
            padding: 5px 10px;
            margin-left: 10px;
            border-radius: 5px;
            color: rgba(255,255,255,1);
            background: rgba(64,64,64,1);
            border: 1px solid rgba(255,255,255,1);
          }
          #${SCRIPTNAME}-panels div.panel > p.buttons button.primary{
            font-weight: bold;
            background: rgba(0,0,0,1);
          }
          #${SCRIPTNAME}-panels div.panel > p.buttons button:hover,
          #${SCRIPTNAME}-panels div.panel > p.buttons button:focus{
            background: rgba(128,128,128,.875);
          }
          #${SCRIPTNAME}-panels .template{
            display: none !important;
          }
          /* ヘルプパネル */
          #${SCRIPTNAME}-helpPanel{
            width: 380px;
          }
          #${SCRIPTNAME}-helpPanel dl{
            display: flex;
            flex-wrap: wrap;
          }
          #${SCRIPTNAME}-helpPanel dl dt{
            width: 120px;
            margin: 2px 0;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-helpPanel dl dt kbd{
            margin-left: 5px;
          }
          #${SCRIPTNAME}-helpPanel dl dd{
            width: 230px;
            margin: 2px 0 2px 10px;
          }
          #${SCRIPTNAME}-helpPanel dl dd.note{
            color: gray;
            font-size: 75%;
            width: 100%;
          }
          #${SCRIPTNAME}-helpPanel dt.disabled,
          #${SCRIPTNAME}-helpPanel dt.disabled + dd,
          #${SCRIPTNAME}-helpPanel h2.disabled,
          #${SCRIPTNAME}-helpPanel h2.disabled + dl{
            opacity: .5;
          }
          #${SCRIPTNAME}-helpPanel dt.hidden{
            display: none;
          }
          /* インジケータ */
          #${SCRIPTNAME}-indicator{
            position: absolute;
            bottom: 0;
            right: 0;
            font-size: 25vh;
            color: rgba( 81,195, 0,1);
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));
            opacity: 0;
            z-index: 101;/*すべてに優先させるつもり*/
            pointer-events: none;
            transition: opacity 250ms;
          }
          #${SCRIPTNAME}-indicator.active{
            opacity: .75;
          }
          #${SCRIPTNAME}-indicator #rewind{
            fill: rgba( 81,195, 0,1);
            width: 25vh;
            height: 25vh;
          }
          #${SCRIPTNAME}-indicator.active #rewind{
            animation: ${SCRIPTNAME}-blink 2s step-end infinite;
          }
          @keyframes ${SCRIPTNAME}-blink{
            50%{opacity: 0}
          }
        </style>
      `,
    },
  };
  const $ = function(s){return document.querySelector(s)};
  const $$ = function(s){return document.querySelectorAll(s)};
  const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  const createElement = function(html){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  const atLeast = function(min, b){
    return Math.max(min, b);
  };
  const atMost = function(a, max){
    return Math.min(a, max);
  };
  const between = function(min, b, max){
    return Math.min(Math.max(min, b), max);
  };
  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(
      SCRIPTNAME + ':',
      /* 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 \((userscript\.html|chrome-extension:)/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+)\)$/)[1] - 6,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|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 + '\n' + new Error().stack);
    return true;
  });
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();