AbemaTV Video Assistant

AbemaTV のビデオ視聴を快適にします。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        AbemaTV Video Assistant
// @namespace   knoa.jp
// @description AbemaTV のビデオ視聴を快適にします。
// @include     https://abema.tv/*
// @version     1.0.3
// @grant       none
// ==/UserScript==

// console.log('AbemaTV? => hireMe()');
(function(){
  const SCRIPTNAME = 'VideoAssistant';
  const DEBUG = false;/*
[update] 1.0.3
再生速度が「次のエピソード」への移動でリセットされてしまうバグを解消。(またしてもアベマ公式の謎仕様に起因(。◟‸◞。✿))

[bug]

[to do]
video以外のページならgoneでcss外すか。
時刻ズレが気持ち悪いので全体時間も上書きしよう。

[to research]
無音時に限り音量が記憶されない公式のバグ
CMで一瞬ヘッダがでるのは公式の仕様

[possible]
上部ナビゲーション番組表の隣にマイリスト昇格
ビデオ視聴ページ
  戻る進む時に画面中央にインジケータ
  コメント数ヒートマップ ?
マイリストページ
  一覧性向上や分類など
  「プレミアムなら」を明示
  期限切れをすべて削除するボタン
ビデオトップページ
  removed_genre:   {TYPE: 'object', DEFAULT: {}},*(削除したジャンル)*
  removed_heading: {TYPE: 'object', DEFAULT: {}},*(削除した見出し)*

[requests]

[not to do]
:has疑似セレクタ実装はまだまだ先になりそう
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const CONFIGS = {
    /* ビデオ再生 */
    auto_play:       {TYPE: 'bool',   DEFAULT: 1 },/*自動で再生を開始する*/
    keep_screen:     {TYPE: 'bool',   DEFAULT: 0 },/*ブラウザ全画面かどうかを記憶する*/
    keep_speed:      {TYPE: 'bool',   DEFAULT: 0 },/*再生速度を記憶する*/
    /* 次のエピソードへの移動 */
    show_next:       {TYPE: 'bool',   DEFAULT: 1 },/*次のエピソードへの移動ボタンを出す*/
    next_at_end:     {TYPE: 'bool',   DEFAULT: 0 },/* ビデオの最後まで再生してから出す*/
    next_countdown:  {TYPE: 'bool',   DEFAULT: 1 },/* カウントダウンして自動移動する*/
  };
  const URLS = {
    CHANNELS: 'https://abema.tv/channels/',/*見逃し番組視聴ページ(未来や期限切れも含む)*/
    VIDEO:    'https://abema.tv/video/episode/',/*ビデオ視聴ページ(期限切れも含む)*/
  };
  const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/
  const RETRY = 10;/*必要な要素が見つからずあきらめるまでの試行回数*/
  let site = {
    videoTargets: {
      video: () => $('.com-a-Video__container video[src]'),/*CMとは区別する*/
    },
    adVideoTargets: {
      adContainer: () => $('#videoAdContainer'),
      adVideos: () => $$('#videoAdContainer video'),/*CM*/
      adVideoController: () => $('.com-video_ad-VideoAdControlBar__controls'),
    },
    elementTargets: {
      player: () => $('.c-tv-SlotPlayerContainer') || $('.c-vod-PlayerContainer-wrapper'),/*タイムシフトまたはビデオ*/
      controlBackground: () => $('.com-vod-VideoControlBar__bg'),
      playButton: () => $('.com-vod-VideoControlBar__play-handle'),
      currentTime: () => $('.com-vod-VODTime > span > time'),
      playbackRateButton: () => $('.com-vod-VideoControlBar__playback-rate'),
      VolumeController: () => $('.com-vod-VideoControlBar__volume'),
    },
    nextButtonTargets: {/*次のエピソードへ*/
      nextButton: () => $('.com-vod-VODNextProgramInfo'),
      nextButtonAnchor: () => $('.com-vod-VODNextProgramInfo a[href]'),
      nextButtonCount: () => $('.com-video-MediaInfoCard__count'),
      nextButtonCountPie: () => $('.com-video-MediaInfoCard__thumbnail > span'),
      nextButtonCancel: () => $('.com-vod-VODNextProgramInfo__close-button'),
    },
    screenButtonTargets: {/*画面サイズボタン*/
      fullScreenInBrowserButton: () => $('.com-vod-VideoControlBar__screen-controller'),
      fullScreenButton: () => $('.com-vod-VideoControlBar__screen-controller + .com-vod-VideoControlBar__screen-controller'),
    },
    screenButtonOnAdTargets: {/*CM中の画面サイズボタン*/
      fullScreenInBrowserButtonOnAd: () => $('.com-video_ad-VideoAdControlBar__button'),
      fullScreenButtonOnAd: () => $('.com-video_ad-VideoAdControlBar__button + .com-video_ad-VideoAdControlBar__button'),
    },
    get: {
      playbackImage: () => $('.com-vod-VODScreen-playback-image'),/*再生/停止のオーバーレイインジケータ*/
      playbackIcon: (button) => button.querySelector('use[*|href^="/images/icons/playback.svg"]'),/*再生/停止ボタンの再生アイコン*/
      currentPlaybackRate: () => $('.com-a-RadioButton--checked input[name="vod-setting-playbackRate"]'),/*現在選択中の再生速度*/
      targetPlaybackRate: (value) => $(`input[name="vod-setting-playbackRate"][value="${value}"]`),/*目的の再生速度*/
      miniScreenInBrowserIcon: (button) => button.querySelector('use[*|href^="/images/icons/mini_screen_in_browser.svg"]'),
      fullScreenInBrowserIcon: (button) => button.querySelector('use[*|href^="/images/icons/full_screen_in_browser.svg"]'),
      miniScreenIcon: (button) => button.querySelector('use[*|href^="/images/icons/mini_screen.svg"]'),/*元の小画面または中画面に戻る*/
      fullScreenIcon: (button) => button.querySelector('use[*|href^="/images/icons/full_screen.svg"]'),
      nextProgramThumbnail: (button) => button.querySelector('a img[alt]'),
    },
  };
  let elements = {}, storages = {}, configs = {}, timers = {};
  let core = {
    initialize: function(){
      html = document.documentElement;
      core.config.read();
      core.addStyle();
      core.panel.createPanels();
      core.listenUserActions();
      core.checkUrl();
    },
    checkUrl: function(){
      let previousUrl = '';
      const videoPages = [URLS.CHANNELS, URLS.VIDEO];
      const isVideoPage = () => videoPages.some(url => location.href.startsWith(url));
      const wasVideoPage = () => videoPages.some(url => previousUrl.startsWith(url));
      setInterval(function(){
        switch(true){
          case(location.href === previousUrl): return;/*URLが変わってない*/
          case(isVideoPage()):/*ビデオ視聴ページ*/
            core.videoReady();/*ビデオ視聴ページに来た*/
            break;
          default:/*ビデオ視聴ページではない*/
            break;
        }
        previousUrl = location.href;
      }, 1000);
    },
    videoReady: function(){
      let previousSrc = (elements.video) ? elements.video.src : null;
      core.getTargets(site.videoTargets, RETRY).then(() => {
        if(elements.video.src === previousSrc) setTimeout(core.videoReady, 1000);/*まだDOMが差し替わってない*/
        log("I'm ready for video.");
        html.classList.add(SCRIPTNAME);
        core.setAutoPlay();
        core.adVideosReady();
        core.elementsReady();
      });
    },
    adVideosReady: function(){
      core.getTargets(site.adVideoTargets, RETRY).then(() => {
        log("I'm ready for ad videos.");
        core.keepScreen();
        core.makeAdsPausable();
        core.waitForAdEnded();
      });
    },
    elementsReady: function(){
      core.getTargets(site.elementTargets, RETRY).then(() => {
        log("I'm ready for elements.");
        core.config.createButton();
        core.replaceVideoTime();
        core.keepScreen();
        core.keepSpeed();
        core.alterNextButton();
        core.modifyPlayButton();
      });
    },
    getTargets: function(targets, retry = 0){
      const get = function(resolve, reject, retry){
        for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
          let selected = targets[key]();
          if(selected){
            if(selected.length) selected.forEach((s) => s.dataset.selector = key);
            else selected.dataset.selector = key;
            elements[key] = selected;
          }else{
            if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
            log(`Not found: ${key}, retrying... (left ${retry})`);
            return setTimeout(get, 1000, resolve, reject, retry);
          }
        }
        resolve();
      };
      return new Promise(function(resolve, reject){
        get(resolve, reject, retry);
      });
    },
    listenUserActions: function(){
      document.addEventListener('fullscreenchange', function(e){
        if(document.fullscreenElement){/*フルスクリーンなら*/
          document.fullscreenElement.appendChild(elements.panels);
        }else{
          document.body.appendChild(elements.panels);
        }
      });
    },
    makeAdsPausable: function(){
      let adContainer = elements.adContainer, adVideoController = elements.adVideoController, cuurentAd = undefined;
      const toggle = function(e){
        if(!cuurentAd){
          cuurentAd = Array.from(elements.adVideos).find((v) => !v.paused);/*elements.にしないとlistener登録した時点の古いDOMを引きずる*/
          if(cuurentAd) cuurentAd.pause();
        }else{
          cuurentAd.play();
          cuurentAd = undefined;
        }
      };
      if(!adContainer.isListeningClick){/*要素ごとに1度だけ*/
        adContainer.isListeningClick = true;
        adContainer.addEventListener('click', function(e){
          if(adVideoController.contains(e.target)) return;
          toggle(e);
        }, {capture: true});
      }
      if(!core.makeAdsPausable.isListeningKeydown){/*スクリプトごとに1度だけ*/
        core.makeAdsPausable.isListeningKeydown = true;
        if(html.classList.contains('ShortcutKeyController')){
          window.addEventListener('keydown', function(e){
            if(['input', 'textarea'].includes(document.activeElement.localName)) return;
            if(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey) return;
            if(['k', ' ', 'Enter'].includes(e.key)) toggle(e);
          }, {capture: true});
        }
      }
    },
    waitForAdEnded: function(){
      let adVideos = elements.adVideos;
      adVideos.forEach((v) => v.addEventListener('ended', core.elementsReady));
    },
    replaceVideoTime: function(){
      let video = elements.video, currentTime = elements.currentTime, replacedCurrentTime = currentTime.cloneNode(true);
      const secondsToTime = function(seconds){
        let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0');
        let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60);
        if(h) return h + ':' + zero(m) + ':' + zero(s);
        else  return m + ':' + zero(s);
      };
      const tiktok = function(e){/*なおアベマ公式はdelay調整無しで1秒ごとに四捨五入*/
        let delay = ((1 - video.currentTime%1) / video.playbackRate)*1000;/*次の秒になるまでの時間(足りなくてももう一度呼ばれて問題ない)*/
        if(0.5 < delay) replacedCurrentTime.textContent = secondsToTime(video.currentTime);
        clearInterval(timers.tiktok), timers.tiktok = setTimeout(tiktok, delay);
      };
      /* 独自要素に置き換える */
      replacedCurrentTime.dataset.selector = 'replacedCurrentTime';
      currentTime.parentNode.insertBefore(replacedCurrentTime, currentTime);
      /* 再生中に独自要素を更新し続ける */
      if(!video.paused) tiktok();
      video.addEventListener('play', tiktok);
      video.addEventListener('pause', function(e){
        clearInterval(timers.tiktok);
      });
      video.addEventListener('seeked', function(e){
        replacedCurrentTime.textContent = secondsToTime(video.currentTime);
      });
    },
    setAutoPlay: function(){
      let video = elements.video, nextButton = elements.nextButton || createElement(), playbackImage = site.get.playbackImage() || createElement();
      let conditions = [[/*停止状態にしたい条件*/
        (configs.auto_play === 0),/*自動で再生を開始しない*/
        (location.href.endsWith('?next=true') === false),/*1つめのエピソードである*/
      ], [
        (configs.auto_play === 0),/*自動で再生を開始しない*/
        (nextButton.videoWasPaused === true),/*ビデオが停止中であった*/
        (nextButton.clicked === true),/*リンクをみずからクリック(カウントダウンではない)*/
      ]];
      const pause = function(e){
        video.pause();
        setTimeout(function(){playbackImage.style.visibility = ''}, 1500);/*1000で足りないこともあったので*/
        video.removeEventListener('canplay', pause);
      };
      if(conditions.some((set) => set.every((c) => (c === true)))){
        playbackImage.style.visibility = 'hidden';
        video.addEventListener('canplay', pause);/*一瞬音声が流れてしまうこともある*/
      }
      /* setAutoPlayが呼ばれる(新しい番組)ごとにリセット */
      nextButton.videoWasPaused = false;
      nextButton.clicked = false;
    },
    modifyPlayButton: function(){
      let video = elements.video, playButton = elements.playButton;
      let conditions = [/*アイコンを修正すべき条件*/
        (configs.auto_play === 0),/*自動で再生を開始しない*/
        (video.paused === true),/*停止している*/
        (site.get.playbackIcon(playButton) === null),/*にもかかわらずアイコンが停止状態を示していない!!*/
      ];
      if(conditions.every((c) => c === true)) playButton.click();/*アイコンだけ停止状態になる*/
    },
    keepScreen: function(){
      /* fullScreenInBrowserButton(小画面と中画面のトグル) + fullScreenButton(全画面:DOM再取得が必要) */
      Promise.race([
        core.getTargets(site.screenButtonTargets, RETRY),
        core.getTargets(site.screenButtonOnAdTargets, RETRY),
      ]).then(() => {
        let video = elements.video;
        let fullScreenInBrowserButton = [elements.fullScreenInBrowserButton, elements.fullScreenInBrowserButtonOnAd].find((e) => e && e.isConnected);
        let fullScreenButton = [elements.fullScreenButton, elements.fullScreenButtonOnAd].find((e) => e && e.isConnected);
        const DELAY = 1000;/*画面サイズの変更にかかる時間を確保*/
        const getCurrentScreen = function(){
          switch(true){
            case(site.get.fullScreenInBrowserIcon(fullScreenInBrowserButton) !== null):
              return 'miniScreenInBrowser';/*小画面*/
            case(site.get.miniScreenInBrowserIcon(fullScreenInBrowserButton) !== null):
              return 'fullScreenInBrowser';/*中画面*/
            case(site.get.miniScreenIcon(fullScreenButton) !== null):
              return 'fullScreen';/*全画面*/
          }
        };
        const saveScreen = function(e){
          Storage.save('screen', getCurrentScreen());
        };
        const setScreen = function(){
          switch(Storage.read('screen')){
            case('miniScreenInBrowser'):/*小画面*/
              return;
            case('fullScreenInBrowser'):/*中画面*/
              return fullScreenInBrowserButton.click(); 
            case('fullScreen'):/*全画面*/
              return fullScreenButton.click();/*ブラウザ仕様につき機能しない*/
          }
        };
        if(!fullScreenInBrowserButton.isListeningClick){/*ボタンごとに1度だけ*/
          fullScreenInBrowserButton.isListeningClick = true;
          fullScreenInBrowserButton.addEventListener('click', function(e){
            setTimeout(saveScreen, DELAY);
          });
        }
        if(!core.keepScreen.isListeningFullscreenchange){/*スクリプトごとに1度だけ*/
          core.keepScreen.isListeningFullscreenchange = true;
          document.addEventListener('fullscreenchange', function(e){
            setTimeout(saveScreen, DELAY);
            if(!document.fullscreenElement) setTimeout(core.keepScreen, DELAY);/*ボタンが差し替えられるので*/
          });
        }
        if(video.setScreen !== location.href){/*ビデオ内容ごとに1度だけ*/
          video.setScreen = location.href;
          if(configs.keep_screen) setScreen();/*初回の視聴画面サイズを再現*/
        }
      });
    },
    keepSpeed: function(){
      let video = elements.video, playbackRateButton = elements.playbackRateButton;
      const getCurrentSpeed = function(){
        return site.get.currentPlaybackRate().value || 1;
      };
      const saveSpeed = function(e){
        Storage.save('speed', getCurrentSpeed());
      };
      const setSpeed = function(){
        let speed = Storage.read('speed') || 1;
        let input = site.get.targetPlaybackRate(speed);
        if(input) input.click();/*checkだけではアベマのDOMが反応しない*/
      };
      if(!playbackRateButton.isListeningRatechange){
        playbackRateButton.isListeningRatechange = true;
        /* video要素へのratechangeイベントだと、次のエピソードに移ったときにアベマによる強制リセットで元に戻ってしまう */
        playbackRateButton.addEventListener('click', function(e){
          log(e);
          setTimeout(saveSpeed, 1000);
        });
      }
      setSpeed();
    },
    alterNextButton: function(){
      if(!location.href.startsWith(URLS.VIDEO)) return;/*次のエピソードが表示されるのはビデオのみ*/
      core.getTargets(site.nextButtonTargets, RETRY).then(() => {
        let video = elements.video, nextButton = elements.nextButton, nextButtonAnchor = elements.nextButtonAnchor;
        let nextButtonCount = elements.nextButtonCount, nextButtonCancel = elements.nextButtonCancel;
        /* ビデオ終了時の独自カウントダウン(再生アイコンのアニメーションは割愛) */
        const COUNT = 10;
        const countdown = function(){
          let node = nextButtonCount.firstChild, count = COUNT;
          node.data = String(count);
          clearInterval(timers.countdown), timers.countdown = setInterval(function(){
            node.data = String(--count);
            if(count === 0){
              clearInterval(timers.countdown);
              nextButton.dataset.shown = 'false';
              nextButtonAnchor.click();
            }
          }, 1000);
        };
        /* 番組終了間際に自動でボタンが出現する瞬間を検知する */
        observe(nextButton, function(records){
          if(nextButtonCancel.disabled) return;/*閉じた(つもり)のときは何もしない*/
          if(video.ended){
            nextButton.dataset.shown = 'true';/*閉じなくてもよい*/
            if(configs.next_countdown) countdown();/*独自カウントダウン*/
            return;
          }
          switch(true){
            case(configs.show_next === 0):/*すぐ閉じて表示もさせない*/
              nextButtonCancel.click();
              nextButton.dataset.shown = 'false';
              break;
            case(configs.next_at_end === 1):/*すぐ閉じて表示もさせない*/
              nextButtonCancel.click();
              nextButton.dataset.shown = 'false';
              break;
            case(configs.next_countdown === 0):/*すぐ閉じてカウントダウンさせない*/
              nextButtonCancel.click();
              nextButton.dataset.shown = 'true';
              break;
            default:/*閉じずにカウントダウン表示を続ける*/
              nextButton.dataset.shown = 'true';
              break;
          }
        }, {attributes: true, attributeFilter: ['class']});/*公式のclass変化のみを監視する*/
        nextButton.classList.add('observing');/*すでにボタンが出ていた場合のきっかけにする*/
        /* 次のエピソードの自動再生判定のためにボタンの実クリックを記録する */
        nextButtonAnchor.addEventListener('click', function(e){
          nextButton.videoWasPaused = video.paused;
          if(e.isTrusted){
            nextButton.clicked = true;
          }
        });
        /* ボタンの表示を独自に制御 */
        nextButtonCancel.addEventListener('click', function(e){
          if(e.isTrusted){
            nextButton.dataset.shown = 'false';/*実クリックされたらもちろん消す*/
            clearInterval(timers.countdown);/*独自カウントダウンしていたら止める*/
            return;
          }
          setTimeout(function(){nextButtonCancel.disabled = false}, 1000);/*クリックはいつでもできる(正規のクリック後に上書き)*/
        });
        if(!video.isListeningSeeking){/*ビデオごとに1度だけ*/
          video.isListeningSeeking = true;
          video.addEventListener('seeking', function(e){
            let thumbnail = site.get.nextProgramThumbnail(nextButton);
            if(!thumbnail || thumbnail.alt === '') return;/*次のエピソードなし*/
            if(nextButton.dataset.shown === 'true') nextButton.dataset.shown = 'false';
            else if(video.currentTime + 1/*許容範囲*/ > video.duration) nextButton.dataset.shown = 'true';
          });
        }
      });
    },
    config: {
      read: function(){
        /* 保存済みの設定を読む */
        configs = Storage.read('configs') || {};
        /* 未定義項目をデフォルト値で上書きしていく */
        Object.keys(CONFIGS).forEach((key) => {if(configs[key] === undefined) configs[key] = CONFIGS[key].DEFAULT});
      },
      save: function(new_config){
        configs = {};/*CONFIGSに含まれた設定値のみ保存する*/
        /* CONFIGSを元に文字列を型評価して値を格納していく */
        Object.keys(CONFIGS).forEach((key) => {
          /* 値がなければデフォルト値 */
          if(new_config[key] === "") return configs[key] = CONFIGS[key].DEFAULT;
          switch(CONFIGS[key].TYPE){
            case 'bool':
              configs[key] = (new_config[key]) ? 1 : 0;
              break;
            case 'int':
              configs[key] = parseInt(new_config[key]);
              break;
            case 'float':
              configs[key] = parseFloat(new_config[key]);
              break;
            default:
              configs[key] = new_config[key];
              break;
          }
        });
        Storage.save('configs', configs);
      },
      createButton: function(){
        if(elements.configButton && elements.configButton.isConnected) return;
        /* 再生速度ボタンを元に設定ボタンを追加する */
        let configButton = elements.configButton = createElement(core.html.configButton());
        configButton.className = elements.playbackRateButton.className;
        configButton.addEventListener('click', core.config.toggle);
        elements.playbackRateButton.parentNode.insertBefore(configButton, elements.playbackRateButton);/*元のDOM位置関係にできるだけ影響を与えない*/
      },
      open: function(){
        core.panel.open(elements.configPanel || core.config.createPanel());
      },
      close: function(){
        core.panel.close(elements.configPanel);
      },
      toggle: function(){
        core.panel.toggle(elements.configPanel || core.config.createPanel(), core.config.open, core.config.close);
      },
      createPanel: function(){
        let configPanel = elements.configPanel = createElement(core.html.configPanel());
        configPanel.querySelector('button.cancel').addEventListener('click', core.config.close);
        configPanel.querySelector('button.save').addEventListener('click', function(e){
          let inputs = configPanel.querySelectorAll('input'), new_configs = {};
          for(let i = 0, input; input = inputs[i]; i++){
            switch(CONFIGS[input.name].TYPE){
              case('bool'):
                new_configs[input.name] = (input.checked) ? 1 : 0;
                break;
              case('object'):
                if(!new_configs[input.name]) new_configs[input.name] = {};
                new_configs[input.name][input.value] = (input.checked) ? 1 : 0;
                break;
              default:
                new_configs[input.name] = input.value;
                break;
            }
          }
          core.config.save(new_configs);
          core.config.close();
          /* 新しい設定値で再スタイリング */
          core.addStyle();
        }, true);
        configPanel.querySelector('input[name="show_next"]').addEventListener('click', function(e){
          let selectors = ['next_at_end', 'next_countdown'];
          selectors.forEach(selector => {
            let sub = configPanel.querySelector(`input[name="${selector}"]`);
            sub.disabled = !sub.disabled;
            sub.parentNode.parentNode.classList.toggle('disabled');
          });
        }, true);
        configPanel.keyAssigns = {
          'Escape': core.config.close,
        };
        return configPanel;
      },
    },
    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: {
      configButton: () => `
        <button id="${SCRIPTNAME}-config-button" title="${SCRIPTNAME} 設定"><svg width="20" height="20" role="img"><use xlink:href="/images/icons/config.svg#svg-body"></use></svg></button>
      `,
      configPanel: () => `
        <div class="panel" id="${SCRIPTNAME}-config-panel" data-name="configPanel" data-order="1">
          <h1>${SCRIPTNAME}設定</h1>
          <fieldset>
            <legend>ビデオ再生</legend>
            <p><label>自動で再生を開始する:               <input type="checkbox" name="auto_play"   value="${configs.auto_play}"   ${configs.auto_play   ? 'checked' : ''}></label></p>
            <p><label>ブラウザ全画面かどうかを記憶する:   <input type="checkbox" name="keep_screen" value="${configs.keep_screen}" ${configs.keep_screen ? 'checked' : ''}></label></p>
            <p><label>再生速度を記憶する:                 <input type="checkbox" name="keep_speed"  value="${configs.keep_speed}"  ${configs.keep_speed  ? 'checked' : ''}></label></p>
            <legend>次のエピソードへの移動</legend>
            <p><label>次のエピソードへの移動ボタンを出す: <input type="checkbox" name="show_next"   value="${configs.show_next}"   ${configs.show_next   ? 'checked' : ''}></label></p>
            <p class="sub ${configs.show_next ? '' : 'disabled'}"><label>最後まで再生し終えたときだけ出す: <input type="checkbox" name="next_at_end"    ${configs.next_at_end    ? 'checked' : ''} ${configs.show_next ? '' : 'disabled'}></label></p>
            <p class="sub ${configs.show_next ? '' : 'disabled'}"><label>カウントダウンして自動移動する:   <input type="checkbox" name="next_countdown" ${configs.next_countdown ? 'checked' : ''} ${configs.show_next ? '' : 'disabled'}></label></p>
          </fieldset>
          <p class="buttons"><button class="cancel">キャンセル</button><button class="save primary">保存</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}`} */
          /* ウィンドウサイズ可変対応 */
          body{
            overflow-x: hidden;/*横スクロールバーを出さないように*/
          }
          [data-selector="player"]{
            max-width: 100vw;/*小さいウィンドウにもできるだけビデオサイズを追随させる*/
          }
          /* コントローラUI */
          [data-selector="controlBackground"]{
            background: linear-gradient(transparent, rgba(0,0,0,.1), rgba(0,0,0,.3), rgba(0,0,0,.6));/*影を薄めつつ立ち上がりも優しく*/
          }
          /* 現在時刻 */
          [data-selector="currentTime"]{
            display: none;
          }
          [data-selector="replacedCurrentTime"]{
          }
          /* 設定ボタン */
          #${SCRIPTNAME}-config-button{
            fill: white;
            animation: ${SCRIPTNAME}-show 250ms 1;
          }
          @keyframes ${SCRIPTNAME}-show{
            from{
              opacity: 0;
            }
            to{
              opacity: 1;
            }
          }
          /* 再生速度ボタン */
          [data-selector="playbackRateButton"] > div > div{
            padding: 0 10px 5px;/*スライダの表示判定を広くしてあげる*/
            margin-bottom: -5px;
            box-sizing: content-box;
          }
          /* ボリュームボタン(CSS指定が異なる(!)リアルタイム放送に影響を与えないように注意) */
          [data-selector="player"] [data-selector="VolumeController"] > div{
            width: 100%;/*スライダの表示判定を広くしてあげる*/
            height: 100%;
          }
          [data-selector="player"] [data-selector="VolumeController"] > div > button{
            position: relative;
            top: 50%;
            transform: translate(0, -50%);
          }
          [data-selector="player"] [data-selector="VolumeController"] [class$="slider-container"]/*スライダ*/{
            padding: 0 10px;/*クリック判定範囲を広くしてあげる*/
            left: 50%;
            transform: translate(-50%, -100%);
          }
          [data-selector="player"] [data-selector="VolumeController"] button > svg{
            vertical-align: bottom;/*アベマのわずかなズレを修正*/
          }
          /* 次のエピソードへの自動移動ボタン */
          [data-selector="nextButton"]{
            display: ${configs.show_next ? 'block' : 'none'};
            width: 0 !important;/*アベマ公式はここの固定幅で表示制御しているが*/
          }
          [data-selector="nextButton"][data-shown="true"]{/*アベマ公式を上書きして表示させる*/
            overflow: visible;
          }
          [data-selector="nextButton"][data-shown="true"] > div{
            transform: translateX(-100%);/*固定幅に依存せずここで表示制御する*/
            opacity: 1;
          }
          [data-selector="nextButtonCount"],
          [data-selector="nextButtonCountPie"]{
            visibility: ${configs.next_countdown ? 'visible' : 'hidden'};
          }
          /* パネル共通 */
          #${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 li,
          #${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}-panels[data-panels="2"] div.panel:nth-child(1){
            transform: translate(-100%, 50%);
          }
          #${SCRIPTNAME}-panels[data-panels="2"] div.panel:nth-child(2){
            transform: translate(0%, 50%);
          }
          #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(1){
            transform: translate(-150%, 50%);
          }
          #${SCRIPTNAME}-panels[data-panels="3"] div.panel:nth-child(3){
            transform: translate(50%, 50%);
          }
          /* 設定パネル */
          #${SCRIPTNAME}-config-panel{
            width: 360px;
          }
          #${SCRIPTNAME}-config-panel fieldset p{
            padding-left: calc(10px + 1em);
          }
          #${SCRIPTNAME}-config-panel fieldset p:not(.note):hover{
            background: rgba(255,255,255,.25);
          }
          #${SCRIPTNAME}-config-panel fieldset p.disabled{
            opacity: .5;
          }
          #${SCRIPTNAME}-config-panel fieldset .sub{
            padding-left: calc(10px + 2em);
          }
          #${SCRIPTNAME}-config-panel label{
            display: block;
          }
          #${SCRIPTNAME}-config-panel input{
            width: 80px;
            height: 20px;
            position: absolute;
            right: 10px;
          }
          #${SCRIPTNAME}-config-panel input[type="text"]{
            width: 160px;
          }
          #${SCRIPTNAME}-config-panel input[type="text"]:invalid{
            border: 1px solid rgba(255, 0, 0, 1);
            background: rgba(255, 0, 0, .5);
          }
          #${SCRIPTNAME}-config-panel p.note{
            color: gray;
            font-size: 75%;
            padding-left: calc(10px + 1.33em);/*75%ぶん割り戻す*/
          }
        </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)}});
  class Storage{
    static key(key){
      return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
    }
    static save(key, value, expire = null){
      key = Storage.key(key);
      localStorage[key] = JSON.stringify({
        value: value,
        saved: Date.now(),
        expire: expire,
      });
    }
    static read(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.value === undefined) return data;
      if(data.expire === undefined) return data;
      if(data.expire === null) return data.value;
      if(data.expire < Date.now()) return localStorage.removeItem(key);
      return data.value;
    }
    static delete(key){
      key = Storage.key(key);
      delete localStorage.removeItem(key);
    }
    static saved(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.saved) return data.saved;
      else return undefined;
    }
  }
  const $ = function(s){return document.querySelector(s)};
  const $$ = function(s){return document.querySelectorAll(s)};
  const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  const createElement = function(html = '<span></span>'){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  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;
  });
  const time = function(label){
    const BAR = '|', TOTAL = 100;
    switch(true){
      case(label === undefined):/* time() to output total */
        let total = 0;
        Object.keys(time.records).forEach((label) => total += time.records[label].total);
        Object.keys(time.records).forEach((label) => {
          console.log(
            BAR.repeat((time.records[label].total / total) * TOTAL),
            label + ':',
            (time.records[label].total).toFixed(3) + 'ms',
            '(' + time.records[label].count + ')',
          );
        });
        time.records = {};
        break;
      case(!time.records[label]):/* time('label') to start the record */
        time.records[label] = {count: 0, from: performance.now(), total: 0};
        break;
      case(time.records[label].from === null):/* time('label') to re-start the lap */
        time.records[label].from = performance.now();
        break;
      case(0 < time.records[label].from):/* time('label') to add lap time to the record */
        time.records[label].total += performance.now() - time.records[label].from;
        time.records[label].from = null;
        time.records[label].count += 1;
        break;
    }
  };
  time.records = {};
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();