AbemaTV Shortcut Key Controller

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

目前為 2018-12-05 提交的版本,檢視 最新版本

// ==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);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址