AbemaTV Shortcut Key Controller

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

当前为 2018-12-05 提交的版本,查看 最新版本

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

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'ShortcutKeyController';
  const DEBUG = false;/*
  [update]
  軽微な修正。

  [to do]
  音量、内部では小数点で保持するようにすればヌルヌルでも0.1刻みとかできるかも
  これもdiv.panelsに入れないとヘルプでスクロールバーが出ちゃう(Firefoxのみ?)

  [possible]
  Escapeでcloser?
  Keyboardで音量、スクロール文字の透明度?

  [requests]
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const DUMMY = document.createElement('span');
  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="TimetableViewer-button"]'); 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^="フルスクリーン"] + div + div > div'); return node ? node : DUMMY;},
      commentTextarea: function(){let node = $('textarea[placeholder="コメントを入力"]'); 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 = $('use[*|href^="/images/icons/playback.svg"]'); return node ? node.parentNode.parentNode.nextElementSibling : DUMMY;},
      playButton: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode.previousElementSibling : DUMMY;},
      rewindButton: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode : DUMMY;},
      advancesButton: function(){let node = $('use[*|href^="/images/icons/advances_30.svg"]'); return node ? node.parentNode.parentNode : 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;
      }
      volume = Math.min(Math.max(Math.round(volume), 0), 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.timeshift(e);
      }
    },
  };
  let globals = {}, elements = {}, indicatorTimer;
  let core = {
    initialize: function(){
      window.addEventListener('wheel', site.assign, true);
      window.addEventListener('keydown', site.assign, true);
      core.appendVolumeIndicator();
      core.addStyle();
    },
    /* 音量表示の準備 */
    appendVolumeIndicator: function(e){
      elements.indicator = createElement(core.html.volumeIndicator());
      document.body.appendChild(elements.indicator);
    },
    /* 音量表示 */
    indicateVolume: function(volume){
      elements.indicator.textContent = volume;
      elements.indicator.classList.add('active');
      clearTimeout(indicatorTimer);
      indicatorTimer = setTimeout(function(){
        elements.indicator.classList.remove('active');
      }, 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.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();
          }
          break;
        /* 以下、テキスト入力中は反応しない */
        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 = Math.min(REWIND, video.currentTime);
            video.rewinded = true;
            video.currentTime = video.currentTime - rewind;
            video.playbackRate = CATCHUP;
            setTimeout(function(){
              video.rewinded = false;
              video.playbackRate = 1;
            }, (rewind / (CATCHUP - 1))*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.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.toggleHelp('realtime');
          return e.preventDefault();
      }
    },
    /* タイムシフト */
    timeshift: 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 === '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(elements.indicator.textContent !== 'mute') core.indicateVolume('mute');
          else core.indicateVolume(site.getCurrentVolume());
          return e.preventDefault();
        /* ヘルプ */
        case(e.key === 'h'):
        case(e.key === '/'):
          core.toggleHelp('timeshift');
          return e.preventDefault();
      }
    },
    toggleHelp: function(type){
      core.panel.toggle('help', core.createHelp.bind(null, type));
    },
    createHelp: function(type){
      elements.help = createElement(core.html.help(type));
      elements.help.querySelector('button.ok').addEventListener('click', core.panel.close.bind(null, 'help'));
      core.panel.open('help');
    },
    /* パネル共通 */
    panel: {
      open: function(key){
        elements[key].classList.add('hidden');
        document.body.appendChild(elements[key]);
        animate(function(){
          elements[key].classList.remove('hidden');
        });
        if(!globals.listeningKeypress){
          globals.listeningKeypress = true;
          window.addEventListener('keypress', function(e){
            if(['input', 'textarea'].includes(document.activeElement.localName)) return;
            if(elements[key] && e.key === 'Escape') core.panel.close(key);
          });
        }
      },
      close: function(key){
        elements[key].classList.add('hidden');
        elements[key].addEventListener('transitionend', function(){
          if(!elements[key]) return;
          document.body.removeChild(elements[key]);
          elements[key] = null;
        }, {once: true});
      },
      toggle: function(key, create){
        (!elements[key]) ? create() : core.panel.close(key);
      },
    },
    addStyle: function(){
      let style = createElement(core.html.style());
      document.head.appendChild(style);
      if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style);
      elements.style = style;
    },
    html: {
      volumeIndicator: (type) => `
        <div id="${SCRIPTNAME}-volumeIndicator"></div>
      `,
      help: (type) => `
        <div class="${SCRIPTNAME} panel" id="${SCRIPTNAME}-help">
          <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 === 'timeshift') ? '' : ' 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>
          </dl>
          <p class="buttons"><button class="ok primary">OK</button></p>
        </div>
      `,
      style: () => `
        <style>
          /* パネル共通 */
          .${SCRIPTNAME}.panel{
            position: absolute;
            width: 360px;
            max-height: 100%;/*小さなウィンドウに対応*/
            overflow: auto;
            left: 50%;
            bottom: 50%;
            transform: translate(-50%, 50%);
            z-index: 100;
            background: rgba(0,0,0,.75);
            transition: 500ms ease;
            padding: 5px 0;
          }
          .${SCRIPTNAME}.panel.hidden{
            bottom: 0;
            transform: translate(-50%, 100%) !important;
          }
          .${SCRIPTNAME} h1,
          .${SCRIPTNAME} h2,
          .${SCRIPTNAME} h3,
          .${SCRIPTNAME} h4,
          .${SCRIPTNAME} legend,
          .${SCRIPTNAME} ul,
          .${SCRIPTNAME} ol,
          .${SCRIPTNAME} dl,
          .${SCRIPTNAME} code,
          .${SCRIPTNAME} p{
            color: rgba(255,255,255,1);
            font-size: 14px;
            padding: 2px 10px;
            line-height: 1.4;
          }
          .${SCRIPTNAME} header{
            display: flex;
          }
          .${SCRIPTNAME} header h1{
            flex: 1;
          }
          .${SCRIPTNAME}.panel > p.buttons{
            text-align: right;
            padding: 5px 10px;
          }
          .${SCRIPTNAME}.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}.panel > p.buttons button.primary{
            font-weight: bold;
            background: rgba(0,0,0,1);
          }
          .${SCRIPTNAME}.panel > p.buttons button:hover,
          .${SCRIPTNAME}.panel > p.buttons button:focus{
            background: rgba(128,128,128,.75);
          }
          ${SCRIPTNAME} .template{
            display: none !important;
          }
          /* ヘルプパネル */
          #${SCRIPTNAME}-help{
            width: 380px;
          }
          #${SCRIPTNAME}-help dl{
            display: flex;
            flex-wrap: wrap;
          }
          #${SCRIPTNAME}-help dl dt{
            width: 120px;
            margin: 2px 0;
            background: rgba(0,0,0,.5);
            border-radius: 5px;
          }
          #${SCRIPTNAME}-help dl dt kbd{
            margin-left: 5px;
          }
          #${SCRIPTNAME}-help dl dd{
            width: 230px;
            margin: 2px 0 2px 10px;
          }
          #${SCRIPTNAME}-help dl dd.note{
            color: gray;
            font-size: 75%;
            width: 100%;
          }
          #${SCRIPTNAME}-help dt.disabled,
          #${SCRIPTNAME}-help dt.disabled + dd,
          #${SCRIPTNAME}-help h2.disabled,
          #${SCRIPTNAME}-help h2.disabled + dl{
            opacity: .5;
          }
          #${SCRIPTNAME}-help dt.hidden{
            display: none;
          }
          /* 音量 */
          #${SCRIPTNAME}-volumeIndicator{
            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}-volumeIndicator.active{
            opacity: .75;
          }
        </style>
      `,
    },
  };
  let $ = function(s){return document.querySelector(s)};
  let $$ = function(s){return document.querySelectorAll(s)};
  let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  let createElement = function(html){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  let log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let stack = new Error().stack, callers = stack.match(/^([^/<]+(?=<?@))/gm) || stack.match(/[^. ]+(?= \(<anonymous)/gm) || [];
    console.log(
      SCRIPTNAME + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + stack.match(/:[0-9]+:[0-9]+/g)[1].split(':')[1],/*LINE*/
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '')  + '()',
      ...arguments
    );
  };
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();