Greasy Fork

Greasy Fork is available in English.

AbemaTV Shortcut Key Controller

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

您需要先安装一款用户脚本管理器扩展,例如 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.7.4
// @grant       none
// ==/UserScript==

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ShortcutKeyController';
  const DEBUG = false;/*
[update] 2.7.4
コリコリ型のマウスホイールによる音量調整を、端数スタートの場合もきっかり5単位に。

[bug]

[to do]
コメント空欄なら[←]の10秒戻りを有効に

[possible]
Keyboardで音量[U][D]、スクロール文字の透明度[D]?
[Shift+←→]または[,][.]で再生速度?
統合したらインジケータの位置をコメント一覧の幅分ずらすなど
設定(状況に応じたスクリプトの設定)へショートカット...[,][(P)references][(S)ettings]どれもいまひとつ?

[requests]

[not to do]
Edge: ホイール音量調整できない。(MouseEvent未サポート)
ビデオの[F]で全画面は3モードのトグルにしたいが、フルスクリーンからの復帰が1ボタンなので往復にしかできない。
ビデオで[←]による高速巻き戻しは現状Safariでしか効かない上に各ブラウザも対応に消極的。
ビデオで[,][.]でコマ送りはJSからFPSを取得するすべなし(Firefoxのみvideo.seekToNextFrameを実験中)
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const DUMMY = document.createElement('span');
  const LONGPRESS = 1000;/*長押し判定*/
  const FASTPLAYBACKRATE = 16;/*超速再生速度*/
  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.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 = $('.com-tv-TVFooter__footer-left'); return node ? node : 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 tvFooter = $('.com-tv-TVFooter'); return (tvFooter) ? tvFooter.parentNode : DUMMY;},
      closer: function(){
        /* チャンネル切り替えごとに変わる */
        let videoContainer = $('.com-a-Video__container');
        if(!videoContainer) return log(`Not found: videoContainer`);
        let button = videoContainer.parentNode.firstElementChild;/*インスペクタのクリック判定を奪う要素;アベマの構造にすごく依存する*/
        return button ? button : log(`Not found: closer`);
      },
      timetableHeaders: function(){let nodes = $$('#TimetableViewer-timetable-panel .channels > li > header'); return nodes ? nodes : DUMMY;},
      /* 見逃し・ビデオ */
      video: function(){let node = $('video[src]'); return node ? node : DUMMY;},
      timeshiftContainer: function(){let node = $('.c-tv-SlotPlayerContainer'); return node ? node : DUMMY;},
      timeshiftCommentPane: function(){let node = $('.c-tv-SlotPlayerContainer__comment-wrapper'); return node ? node : DUMMY;},
      videoWrapper: function(){let node = $('.c-vod-PlayerContainer-wrapper'); return node ? node : DUMMY;},
      adCover: function(){let node = $('#videoAdContainer iframe'); 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;},
      miniScreenInBrowserButton: function(){let node = $('.com-vod-VideoControlBar use[*|href^="/images/icons/mini_screen_in_browser.svg"]'); return node ? node.parentNode.parentNode : DUMMY;},
      nextCloseButton: function(){let node = $('.com-vod-VODScreen-next-program-close-button'); return node ? node : DUMMY;},
      nextEpisode: function(){let node = $('.com-video-NextProgramBlock'); return node ? node : DUMMY;},
    },
    isCommentPaneHidden: function(){
      let form = $('form:not([role="search"])');
      return (form) ? (form.parentNode.parentNode.getAttribute('aria-hidden') === 'true') : false;
    },
    isMuted: function(){
      return (site.elements.muteButton().querySelector('use[*|href^="/images/icons/volume_on.svg"]')) ? false : true;
    },
    wheel: function(e){
      let volume = site.getCurrentVolume(), d = -e.deltaY;
      switch(e.deltaMode){
        case(WheelEvent.DOM_DELTA_PIXEL):/*ヌルヌル*/
          switch(true){
            case(            d < -20): volume += (volume <  10) ? d/40 : d/20; break;/*大幅調整*/
            case(-20 <= d && d <  -5): volume += (volume <  10) ? -.20 : -.40; break;/*微調整*/
            case( -5 <= d && d <  -1): volume += (volume <  10) ? -.10 : -.20; break;/*微調整*/
            case( -1 <= d && d <   0): volume += 0; break;/*微量なら0(そうしないともたつく)*/
            case(  0 <= d && d <  +1): volume += 0; break;/*微量なら0(そうしないともたつく)*/
            case( +1 <= d && d <  +5): volume += (volume <= 10) ? +.10 : +.20; break;/*微調整*/
            case( +5 <= d && d < +20): volume += (volume <= 10) ? +.20 : +.40; break;/*微調整*/
            case(+20 <= d           ): volume += (volume <= 10) ? d/40 : d/20; break;/*大幅調整*/
          }
          break;
        case(WheelEvent.DOM_DELTA_LINE):/*カクカク*/
        default:
          switch(true){
            case(d < 0    ): volume += (volume <= 10) ? -1 : (   - volume%5 || -5); break;
            case(    0 < d): volume += (volume <  10) ? +1 : (+5 - volume%5 || +5); break;
          }
          break;
      }
      site.modifyVolume(volume);
    },
    getCurrentVolume: function(){
      let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect();
      if(slider.dataset.volume === undefined){
        return 100 * parseInt(slider.firstElementChild.style.height) / rect.height;
      }else{
        return parseFloat(slider.dataset.volume);
      }
    },
    modifyVolume: function(volume){
      /* 0-100の音量調整に対応する */
      let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect();
      volume = between(0, volume, 100);/*0-100に収める*/
      let options = {
        clientX: rect.x + (rect.width/2),
        clientY: rect.y + (rect.height * (1 - volume/100)) + 1/*ゼロの時は確実にゼロにする*/,
        bubbles: true,
      };
      slider.dispatchEvent(new MouseEvent('mousedown'/*clickだと効かない*/, options));
      slider.dispatchEvent(new MouseEvent('mouseup', options));
      slider.dataset.volume = volume;
      core.indicate(document.createTextNode(Math.round(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 html, elements = {}, timers = {}, configs = {}, keyPressing;
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTNAME);
      window.addEventListener('wheel', site.assign, {capture: true, passive: false});
      window.addEventListener('keydown', site.assign, {capture: true});
      document.addEventListener('fullscreenchange', site.assign, {capture: true});
      core.appendIndicator();
      core.panel.createPanels();
      core.addStyle();
    },
    appendIndicator: function(e){
      elements.indicator = createElement(core.html.indicator());
      document.body.appendChild(elements.indicator);
    },
    indicate: function(node, duration = 1000){
      let indicator = elements.indicator;
      while(indicator.firstChild) indicator.removeChild(indicator.firstChild);
      indicator.appendChild(node);
      indicator.classList.add('active');
      clearTimeout(timers.indicator);
      timers.indicator = setTimeout(function(){
        indicator.classList.remove('active');
      }, duration);
    },
    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.wheel(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]'), rewinded = false, duration = 0;
          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)
            duration = (rewind / (CATCHUP - 1))*1000;
            video.rewinded = rewinded = true;
            video.currentTime = video.currentTime - rewind;
            video.playbackRate = CATCHUP;
            setTimeout(function(){
              video.rewinded = false;
              video.playbackRate = 1;
            }, duration);
          }
          core.indicate(createElement(core.html.rewind(rewinded)), rewinded ? duration : 1000);
          return e.preventDefault();
        /* フルスクリーン */
        case(e.key === 'f'):
          site.elements.fullscreenButton().click();
          return e.preventDefault();
        /* ミュート */
        case(e.key === 'm'):
          site.elements.muteButton().click();
          if(site.isMuted()) core.indicate(document.createTextNode('mute'));
          else site.modifyVolume(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)/*縦ホイールのみ*/):
          /* あらゆる場所でのイベントを拾ってwindow.addEventListenerで一括処理する代償をここで支払う */
          let parents = [site.elements.timeshiftContainer(), site.elements.videoWrapper(), site.elements.adCover()], timeshiftCommentPane = site.elements.timeshiftCommentPane();
          for(let target = e.target; target; target = target.parentNode){
            if(target === timeshiftCommentPane){
              return;
            }else if(parents.includes(target)){
              site.wheel(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 === '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秒進む(長押しで{FASTPLAYBACKRATE}倍速早送り) */
        case(e.key === 'l'):
        case(e.key === 'ArrowRight'):
          if(keyPressing) return;
          keyPressing = true;
          let longPressing = false;
          let advancesTimer = setTimeout(function(){
            longPressing = true;
            let video = site.elements.video();
            let listener = function(e2){
              if(e2.key !== e.key) return;
              video.playbackRate = 1;
              video.currentTime = video.currentTime;/*これで音声との同期ズレを回避*/
              window.removeEventListener('keyup', listener);
            };
            video.playbackRate = FASTPLAYBACKRATE;
            window.addEventListener('keyup', listener, true);
          }, LONGPRESS);
          let advancesListener = function(e){
            window.removeEventListener('keyup', advancesListener, true);
            clearTimeout(advancesTimer);
            keyPressing = false;
            if(longPressing) longPressing = false;
            else site.elements.advancesButton().click();
          };
          window.addEventListener('keyup', advancesListener, true);
          return e.preventDefault();
        /* 次のエピソードへの移動ボタンを閉じる */
        case(e.key === 'Escape'):
        case(e.key === 'x'):
          let nextCloseButton = site.elements.nextCloseButton();
          if(nextCloseButton.isConnected){
            nextCloseButton.click();
            e.stopPropagation();
          }else if(e.key === 'Escape'){/*移動ボタンがないときのみ、ブラウザ全画面を解除*/
            site.elements.miniScreenInBrowserButton().click();
          }
          return e.preventDefault();
        /* 次のエピソードに移動する */
        case(e.key === 'n'):
          site.elements.nextEpisode().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.isMuted()) core.indicate(document.createTextNode('mute'));
          else site.modifyVolume(site.getCurrentVolume());
          return e.preventDefault();
        /* ヘルプ */
        case(e.key === 'h'):
        case(e.key === '/'):
          core.help.toggle('video');
          return e.preventDefault();
        /* フルスクリーン要素対応 */
        case(e.type === 'fullscreenchange'):
          if(document.fullscreenElement){/*フルスクリーンなら*/
            document.fullscreenElement.appendChild(elements.indicator);
            document.fullscreenElement.appendChild(elements.panels);
          }else{
            document.body.appendChild(elements.indicator);
            document.body.appendChild(elements.panels);
          }
          return;
      }
    },
    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]){
                e.preventDefault();
                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){
                  e.preventDefault();
                  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: () => `
        <div id="${SCRIPTNAME}-indicator"></div>
      `,
      rewind: (rewinded) => `
        <svg id="rewind" ${rewinded ? 'class ="rewinded"' : ''} 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${(html.classList.contains('TimetableViewer')) ? '' : ' 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>
            <dt><kbd>[N]</kbd></dt><dd>次のエピソードに移動する ([N]ext)</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           = 101} */
          /* 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 div.panel.hidden *{
            animation: none !important;/*CPU負荷軽減*/
          }
          #${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: 400px;
          }
          #${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: 250px;
            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 dt.disabled + dd + 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: ${configs.panel_zIndex};
            pointer-events: none;
            transition: opacity 250ms;
          }
          #${SCRIPTNAME}-indicator.active{
            opacity: .75;
          }
          #${SCRIPTNAME}-indicator #rewind{
            fill: rgba(195,195,195,.5);
            width: 25vh;
            height: 25vh;
          }
          #${SCRIPTNAME}-indicator #rewind.rewinded{
            fill: rgba( 81,195, 0,1);
          }
          #${SCRIPTNAME}-indicator.active #rewind{
            animation: ${SCRIPTNAME}-blink 2s step-end infinite;
          }
          @keyframes ${SCRIPTNAME}-blink{
            50%{opacity: 0}
          }
          /* ビデオCM */
          #videoAdContainer iframe{
            pointer-events: none;
          }
          #videoAdContainer iframe *{
            pointer-events: auto;
          }
        </style>
      `,
    },
  };
  const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  const getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  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 = '<span></span>'){
    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]+):[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, 'wants', 85, '\n' + new Error().stack);
    return true;
  });
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();