Greasy Fork is available in English.

ABEMA Auto Adjust Playback Position

ABEMAで視聴している番組の遅延(タイムラグ)を減らします。

// ==UserScript==
// @name         ABEMA Auto Adjust Playback Position
// @namespace    https://greasyfork.org/scripts/451815
// @version      9
// @description  ABEMAで視聴している番組の遅延(タイムラグ)を減らします。
// @match        https://abema.tv/*
// @grant        none
// @license      MIT License
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  /* ---------- Settings ---------- */

  // 変更した値はブラウザのローカルストレージに保存するので
  // スクリプトをバージョンアップするたびに書き換える必要はありません。
  // (値が0のときは以前に変更した値もしくは初期値を使用します)

  // 倍速再生時の速度倍率
  // 初期値:1.5
  // 有効値:1.1 ~ 2.0
  let playbackRate = 0;

  // 生放送でのバッファの下限(秒数)
  // 初期値:3
  // 有効値:1 ~ 10
  let liveBuffer = 0;

  // 遅延を積極的に減らす(1:有効 / 2:無効)
  // 初期値:1
  // 有効値:1 ~ 2
  let activelyAdjust = 0;

  /* ------------------------------ */

  const sid = 'AutoAdjustPlaybackPosition',
    ls = JSON.parse(localStorage.getItem(sid) || '{}') || {},
    buffer = {
      archive: 15,
      changeableRate: true,
      cm: false,
      count: 0,
      currentMax: 0,
      currentMin: 0,
      currentTime: 0,
      large: false,
      /** @type {number[]} */
      max: [],
      /** @type {number[]} */
      min: [],
      originalArchive: 0,
      originalLive: 0,
      prev: 0,
      similarLive: false,
    },
    interval = {
      buffer: 0,
      init: 0,
      speed: 0,
      splash: 0,
    },
    moConfig = { childList: true, subtree: true },
    selector = {
      footerText:
        '.com-tv-LinearFooter__feed-super-text,.com-live-event-LiveEventTitle',
      inner: '.c-application-DesktopAppContainer__content',
      liveIcon:
        '.com-a-LegacyIcon__red-icon-path[aria-label="生放送"],.com-live-event-LiveEventViewCounter__icon-wrapper',
      main: 'main',
      splash: '.com-a-Video__video,.com-live-event__LiveEventPlayerView',
      video: 'video[src]:not([style*="display: none;"])',
    };

  /**
   * スタイルシートを追加
   */
  const addCSS = () => {
    const css = `
      #${sid}_Info {
        align-items: center;
        background-color: rgba(0, 0, 0, 0.4);
        border-radius: 4px;
        bottom: 105px;
        color: #fff;
        display: flex;
        font-family: sans-serif;
        justify-content: center;
        left: 90px;
        min-height: 30px;
        min-width: 3em;
        opacity: 0;
        padding: 0.5ex 1ex;
        position: fixed;
        user-select: none;
        visibility: hidden;
        z-index: 2270;
      }
      #${sid}_Info.aapp_show {
        opacity: 0.8;
        visibility: visible;
      }
      #${sid}_Info:hover.aapp_show {
        background-color: rgba(0, 0, 0, 1);
        cursor: pointer;
        opacity: 1;
      }
      #${sid}_Info:hover.aapp_show:after {
        color: #cc9;
        content: "クリックで等速再生";
        padding-left: 1em;
      }
      #${sid}_Info.aapp_hidden {
        opacity: 0;
        transition: opacity 0.5s ease-out, visibility 0.5s ease-out;
        visibility: hidden;
      }
    `,
      style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
  };

  /**
   * 動画の再生速度を変更する
   * @param {number} t 変更する時間(秒)
   * @param {number} r 速度の倍率
   */
  const changePlaybackSpeed = (t, r) => {
    clearTimeout(interval.speed);
    const vi = returnVideo();
    if (vi && t && r) {
      t = (t / r) * 2;
      log('Start change playback speed', t.toFixed(2), r);
      vi.playbackRate = r;
      interval.speed = setTimeout(() => {
        log('Stop change playback speed', t.toFixed(2), r);
        vi.playbackRate = 1;
        resetBufferObj();
      }, t * 1000);
    } else if (vi && vi.playbackRate !== 1) {
      log('Reset playback speed');
      vi.playbackRate = 1;
      resetBufferObj();
    } else {
      log('can not be changed playback speed', t.toFixed(2), r);
      resetBufferObj();
    }
  };

  /**
   * 動画を構成している要素に変更があったとき
   */
  const checkChangeElements = () => {
    const inner = document.querySelector(selector.inner);
    if (inner) {
      setTimeout(() => {
        checkVideoBuffer();
      }, 50);
    }
  };

  /**
   * フッターに番組プログラムのテキストがあるか調べる
   * @returns {boolean}
   */
  const checkExistsFooterText = () => {
    const span = document.querySelector(selector.footerText);
    return span ? true : false;
  };

  /**
   * 生放送を示すアイコンがあるか調べる
   * @returns {boolean}
   */
  const cheeckExistsLiveIcon = () => {
    const svg = document.querySelector(selector.liveIcon);
    return svg ? true : false;
  };

  /**
   * キーボードのキーを押したとき
   * @param {KeyboardEvent} e
   */
  const checkKeyDown = (e) => {
    const isInput =
      e.target instanceof HTMLInputElement ||
      e.target instanceof HTMLTextAreaElement
        ? true
        : false;
    if ((isInput && (e.altKey || e.ctrlKey)) || !isInput) {
      if (e.key === ',' || e.key === '[') {
        e.stopPropagation();
        changePlaybackSpeed(10, 0.5);
      } else if (e.key === '.' || e.key === ']') {
        e.stopPropagation();
        changePlaybackSpeed(30, 2);
      } else if (e.key === '/' || e.key === '\\') {
        e.stopPropagation();
        changePlaybackSpeed(0, 1);
      }
    }
  };

  /**
   * 動画のバッファを調べる
   */
  const checkVideoBuffer = () => {
    clearInterval(interval.buffer);
    interval.buffer = setInterval(() => {
      const vi = returnVideo(),
        tv = /^https:\/\/abema\.tv\/now-on-air\/[\w-]+\/?$/.test(location.href),
        le = /^https:\/\/abema\.tv\/live-event\/[\w-]+\/?$/.test(location.href);
      if (
        (tv && vi?.buffered?.length) ||
        (le && vi?.buffered?.length && cheeckExistsLiveIcon())
      ) {
        const live = cheeckExistsLiveIcon(),
          after = live ? ' [LIVE]' : '',
          cTime = vi.currentTime,
          b = Math.floor((vi.buffered.end(0) - cTime) * 10) / 10,
          dur = vi.duration > 20000000000 ? true : false,
          len = vi.buffered.length,
          rate = vi.playbackRate,
          slow = 0.8;
        if (buffer.currentMax < b) buffer.currentMax = b;
        if (buffer.currentMin > b || buffer.currentMin === 0) {
          buffer.currentMin = b;
          if (buffer.large) {
            if (b < liveBuffer + 2 || (!live && b < buffer.archive - 3)) {
              log('small buffer', buffer.currentMax, b);
              changePlaybackSpeed(0, 1);
            }
          }
        }
        if (buffer.currentTime > cTime && !buffer.cm && tv) {
          const ct = buffer.currentTime - cTime;
          vi.currentTime += ct + 0.3;
          log(
            `${ct.toFixed(2)}秒巻き戻ったので元の位置へシークしました`,
            b,
            buffer.currentMin,
            len,
            'warn'
          );
        }
        if (b > 0 && buffer.changeableRate && dur && checkExistsFooterText()) {
          if (buffer.cm) {
            log('***** CM out *****');
            buffer.cm = false;
          }
          if (len > 1) {
            log('***** vi.buffered.length *****', len);
            for (let i = 0, l = len; i < l; i++) {
              log(i, cTime, vi.buffered.start(i), vi.buffered.end(i));
            }
          }
          if (rate >= 1 && b < 1 && len === 1 && tv) {
            //現在のバッファが1秒未満になったときスロー再生する
            if (live) liveBuffer += 0.5;
            else buffer.archive += 0.5;
            const buff = live ? liveBuffer : buffer.archive;
            log('## A', rate, b, live, buff);
            changePlaybackSpeed(1.2 - b, slow);
          } else if (rate >= 1 && b < 2 && !live && len === 1) {
            //生放送以外で現在のバッファが2秒未満になったときスロー再生する
            buffer.archive += 0.5;
            log('## B', rate, b, live, buffer.archive);
            changePlaybackSpeed(3 - b, slow);
          } else if (rate > 1 && b < 8 && !live && !buffer.similarLive) {
            //生放送以外で倍速再生中に現在のバッファが8秒未満になったとき等速再生に戻す
            log('## C', rate, b, live);
            changePlaybackSpeed(0, 1);
          } else if (
            buffer.prev < b &&
            buffer.currentMax - buffer.currentMin > 1
          ) {
            buffer.max.push(buffer.currentMax);
            buffer.min.push(buffer.currentMin);
            buffer.currentMax = 0;
            buffer.currentMin = 0;
            if (
              //記録したバッファの値が参考にならないと判断した場合は破棄する
              (buffer.max.length === 2 &&
                buffer.min.length === 2 &&
                (buffer.max[0] + 5 < buffer.max[1] ||
                  buffer.min[0] + 5 < buffer.min[1])) ||
              (buffer.max.length > 1 &&
                buffer.min.length > 1 &&
                (buffer.max.slice(-1)[0] < 0 || buffer.min.slice(-1)[0] < 0))
            ) {
              log('** shift', buffer.max.slice(-1)[0], buffer.min.slice(-1)[0]);
              buffer.max.shift();
              buffer.min.shift();
            } else buffer.count += 1;
            let time = 0;
            const maxLast = [...buffer.max].slice(-10),
              minLast = [...buffer.min].slice(-10),
              maxBottom = maxLast.reduce((x, y) => Math.min(x, y)),
              minBottom = minLast.reduce((x, y) => Math.min(x, y)),
              maxDiff =
                Math.round(
                  (maxLast.reduce((x, y) => Math.max(x, y)) - maxBottom) * 100
                ) / 100,
              minDiff =
                Math.round(
                  (minLast.reduce((x, y) => Math.max(x, y)) - minBottom) * 100
                ) / 100,
              lb1 = liveBuffer <= 3.5 ? 2 : liveBuffer <= 6.5 ? 4 : 6,
              lb2 = liveBuffer > 3 ? liveBuffer : 3;
            if (rate === 1) {
              if (
                //最大バッファがbuffer.archiveより多いとき
                //最大バッファがbuffer.archiveに近づくよう倍速再生する
                maxLast.length >= 3 &&
                maxBottom > buffer.archive + 0.5
              ) {
                time = Math.round((maxBottom - buffer.archive) * 100) / 100;
                if (minBottom > 19) {
                  buffer.large = true;
                  time = 999;
                }
                log('## E', time, b, maxDiff, minDiff, live, minBottom);
                changePlaybackSpeed(time, playbackRate);
              } else if (
                //生放送&最小バッファがliveBufferより多い&バッファが安定し続けているとき
                //最小バッファがliveBufferに近づくよう倍速再生する
                live &&
                minLast.length >= 5 &&
                minBottom > liveBuffer + 0.5 &&
                maxDiff < 0.5 &&
                minDiff < 0.5
              ) {
                time = Math.round((minBottom - liveBuffer) * 100) / 100;
                log('## F', time, b, maxDiff, minDiff);
                changePlaybackSpeed(time, playbackRate);
              } else if (
                //生放送でバッファが安定しつづけているとき最小バッファを
                //liveBufferよりも減らすよう(下限は2秒)倍速再生する
                activelyAdjust === 1 &&
                live &&
                minLast.length >= 10 &&
                minBottom > lb1 + 0.5 &&
                maxDiff < 0.5 &&
                minDiff < 0.5
              ) {
                time = Math.round((minBottom - lb1) * 100) / 100;
                log('## G', time, b, maxDiff, minDiff);
                changePlaybackSpeed(time, playbackRate);
              } else if (
                //生放送以外で最小バッファが9秒に近づくよう倍速再生する
                activelyAdjust === 1 &&
                !live &&
                minLast.length >= 10 &&
                minBottom > 9.5 &&
                maxDiff < 0.5
              ) {
                time = Math.round((minBottom - 9) * 100) / 100;
                log('## H', time, b, maxDiff, minDiff);
                changePlaybackSpeed(time, playbackRate);
              } else if (
                //生放送以外でバッファが生放送のように安定し続けているとき
                //最小バッファがliveBuffer(下限は3秒)に近づくよう倍速再生する
                activelyAdjust === 1 &&
                !live &&
                minLast.length >= 10 &&
                minBottom > lb2 + 0.5 &&
                maxDiff < 0.5 &&
                minDiff < 0.5
              ) {
                buffer.similarLive = true;
                time = Math.round((minBottom - lb2) * 100) / 100;
                log('## I', time, b, maxDiff, minDiff);
                changePlaybackSpeed(time, playbackRate);
              }
            }
            log(
              buffer.count,
              'max:[',
              buffer.max.slice(-5).join('  '),
              ']',
              maxBottom,
              maxDiff,
              'min:[',
              buffer.min.slice(-5).join('  '),
              ']',
              minBottom,
              minDiff,
              live,
              len
            );
          }
        } else if (b <= 0 && checkExistsFooterText()) {
          log(
            '** -b',
            rate,
            b,
            buffer.currentMax,
            buffer.currentMin,
            live,
            len
          );
        } else if (!buffer.cm) {
          log('***** CM in *****', dur);
          buffer.cm = true;
          buffer.archive = buffer.originalArchive;
          buffer.count = 0;
          buffer.currentMax = 0;
          buffer.currentMin = 0;
          buffer.currentTime = 0;
          buffer.max = [];
          buffer.min = [];
          liveBuffer = buffer.originalLive;
          if (rate !== 1) {
            changePlaybackSpeed(0, 1);
          }
        }
        if (!buffer.changeableRate && !checkExistsFooterText()) {
          buffer.changeableRate = true;
          log('changeableRate', buffer.changeableRate);
        }
        buffer.currentTime = cTime;
        buffer.prev = b;
        if (rate > 1) {
          showInfo(`▶▶ ×${rate}${after}`);
        } else if (rate > 0 && rate < 1) {
          showInfo(`▶ ×${rate}${after}`);
        }
      } else resetBufferObj();
    }, 100);
  };

  /**
   * 情報を表示する要素をクリックしたとき
   */
  const clickInfo = () => {
    log('clickInfo');
    if (buffer.changeableRate) {
      changePlaybackSpeed(0, 1);
      buffer.changeableRate = false;
      log('changeableRate', buffer.changeableRate);
    }
  };

  /**
   * 情報を表示する要素を作成
   */
  const createInfo = () => {
    const div = document.createElement('div');
    div.id = `${sid}_Info`;
    div.innerHTML = '';
    div.addEventListener('click', clickInfo);
    document.body.appendChild(div);
  };

  /**
   * ページを開いたときに1度だけ実行
   */
  const init = () => {
    log('init');
    setupSettings();
    waitShowVideo();
    addCSS();
    createInfo();
  };

  /**
   * デバッグ用ログ
   * @param {...any} a
   */
  const log = (...a) => {
    if (ls.debug) {
      try {
        if (/^debug$|^error$|^info$|^warn$/.test(a[a.length - 1])) {
          const b = a.pop();
          console[b](sid, a.join('  '));
          showInfo(a[0]);
        } else console.log(sid, a.join('  '));
      } catch (e) {
        if (e instanceof Error) console.error(e.message, ...a);
        else if (typeof e === 'string') console.error(e, ...a);
        else console.error('log error', ...a);
      }
    }
  };

  /**
   * bufferオブジェクトをリセット
   */
  const resetBufferObj = () => {
    buffer.count = 0;
    buffer.currentMax = 0;
    buffer.currentMin = 0;
    buffer.currentTime = 0;
    buffer.large = false;
    buffer.max = [];
    buffer.min = [];
    buffer.prev = 0;
    buffer.similarLive = false;
  };

  /**
   * video要素を返す
   * @returns {HTMLVideoElement|null}
   */
  const returnVideo = () => {
    /** @type {HTMLVideoElement|null} */
    const vi = document.querySelector(selector.video);
    return vi ? vi : null;
  };

  /**
   * ローカルストレージに設定を保存する
   */
  const saveLocalStorage = () => localStorage.setItem(sid, JSON.stringify(ls));

  /**
   * 設定の値を用意する
   */
  const setupSettings = () => {
    /**
     * Settings欄で設定した変数の値が数字以外なら0にする
     * @param {number} a Settings欄の変数
     * @returns {number}
     */
    const num = (a) => (Number.isFinite(Number(a)) ? Number(a) : 0);
    let rate = num(playbackRate),
      buff = num(liveBuffer),
      act = num(activelyAdjust);
    rate = rate > 2 ? 2 : rate < 1.1 && rate !== 0 ? 1.1 : rate;
    buff = buff > 10 ? 10 : buff < 1 && buff !== 0 ? 1 : buff;
    act = act > 2 ? 2 : act < 1 && act !== 0 ? 1 : act;
    playbackRate = ls.playbackRate ? ls.playbackRate : rate ? rate : 1.5;
    liveBuffer = ls.liveBuffer ? ls.liveBuffer : buff ? buff : 3;
    activelyAdjust = ls.activelyAdjust ? ls.activelyAdjust : act ? act : 1;
    if (rate && ls.playbackRate !== rate) {
      playbackRate = rate;
      ls.playbackRate = rate;
      saveLocalStorage();
    }
    if (buff && ls.liveBuffer !== buff) {
      liveBuffer = buff;
      ls.liveBuffer = buff;
      saveLocalStorage();
    }
    if (act && ls.activelyAdjust !== act) {
      activelyAdjust = act;
      ls.activelyAdjust = act;
      saveLocalStorage();
    }
    buffer.originalArchive = buffer.archive;
    buffer.originalLive = liveBuffer;
  };

  /**
   * 情報を表示
   * @param {string} s 表示する文字列
   */
  const showInfo = (s) => {
    const eInfo = document.getElementById(`${sid}_Info`);
    if (eInfo) {
      eInfo.textContent = s ? s : '';
      eInfo.classList.remove('aapp_hidden');
      eInfo.classList.add('aapp_show');
      clearTimeout(interval.info);
      interval.info = setTimeout(() => {
        eInfo.classList.remove('aapp_show');
        eInfo.classList.add('aapp_hidden');
      }, 1000);
    }
  };

  /**
   * 指定時間だけ待つ
   * @param {number} msec
   */
  const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));

  /**
   * ページを開いて動画が表示されたら1度だけ実行
   */
  const startFirstObserve = () => {
    log('startFirstObserve');
    document.addEventListener('keydown', checkKeyDown, true);
    const main = document.querySelector(selector.main);
    if (main) observerC.observe(main, moConfig);
    else log('startFirstObserve: Not found element.', 'error');
  };

  /**
   * ページを開いて動画が表示されるのを待つ
   */
  const waitShowVideo = async () => {
    log('waitShowVideo');
    const splash = () => {
      const sp = document.querySelector(selector.splash);
      if (!sp) {
        log('waitShowVideo: Not found element.', 'error');
        return true;
      }
      const cs = getComputedStyle(sp);
      if (cs?.visibility === 'visible') return true;
      return false;
    };
    await sleep(400);
    clearInterval(interval.splash);
    interval.splash = setInterval(() => {
      const vi = returnVideo();
      if (vi && !isNaN(vi.duration) && splash()) {
        clearInterval(interval.splash);
        startFirstObserve();
      }
    }, 250);
  };

  const observerC = new MutationObserver(checkChangeElements);
  clearInterval(interval.init);
  interval.init = setInterval(() => {
    if (
      /^https:\/\/abema\.tv\/(now-on-air|live-event)\/[\w-]+\/?$/.test(
        location.href
      )
    ) {
      clearInterval(interval.init);
      init();
    }
  }, 1000);
})();