AbemaTV Volume Control

ABEMA視聴中にキーボードやマウスホイールで音量を調整します。

// ==UserScript==
// @name         AbemaTV Volume Control
// @namespace    https://greasyfork.org/ja/scripts/26397
// @version      21
// @description  ABEMA視聴中にキーボードやマウスホイールで音量を調整します。
// @match        https://abema.tv/*
// @grant        none
// @license      MIT License
// @noframes
// ==/UserScript==

(() => {
  'use strict';

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

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

  // キーボードでのボリューム調整量
  // 初期値:5
  // 有効値:1 ~ 20
  let adjustKeyboard = 0;

  // マウスホイールでのボリューム調整量
  // 初期値:1
  // 有効値:1 ~ 20
  let adjustWheel = 0;

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

  const sid = 'VolumeControl',
    ls = JSON.parse(localStorage.getItem(sid) || '{}') || {},
    moConfig = { attributes: true, characterData: true },
    moConfig2 = { childList: true, subtree: true },
    flag = {
      scroll: true,
      type: 0,
      volume: false,
      volumeDownMuted: false,
      wheel: false,
    },
    interval = { info: 0, init: 0, video: 0, wheel: 0 },
    selector = {
      button: '.com-playback-Volume__icon-button',
      fullscreen: ':not(:root):fullscreen',
      inner: '.c-application-DesktopAppContainer__content',
      marker:
        '.com-tv-TVController__volume .com-a-Slider__marker, .com-vod-VideoControlBar__volume .com-a-Slider__marker, .com-vod-LiveEventPayperviewControlBar__volume .com-a-Slider__marker',
      player:
          '.com-tv-TVScreen__player-container, .c-vod-EpisodePlayerContainer-wrapper, .c-tv-TimeshiftPlayerContainerView, .com-live-event-LiveEventPlayerAreaLayout__player',
      slider:
        '.com-tv-TVController__volume .com-a-Slider, .com-vod-VideoControlBar__volume .com-a-Slider, .com-vod-LiveEventPayperviewControlBar__volume .com-a-Slider',
      sliderContainer: '.com-playback-Volume__slider-container',
      sliderHighlighter:
        '.com-tv-TVController__volume .com-a-Slider__highlighter, .com-vod-VideoControlBar__volume .com-a-Slider__highlighter, .com-vod-LiveEventPayperviewControlBar__volume .com-a-Slider__highlighter',
      splash: '.com-a-Video__video, .com-live-event__LiveEventPlayerView',
      tv: '.com-tv-TVScreen__player-container',
      video: 'video[src]:not([style*="display: none;"])',
      vod: '.c-vod-EpisodePlayerContainer-container, .c-tv-TimeshiftPlayerContainerView, .com-live-event-LiveEventPlayerSectionLayout__player-area-inner--video',
      volume:
        '.com-playback-Volume--desktop, .com-vod-VideoControlBar__volume > .com-playback-Volume, .c-vod-EpisodePlayerContainer-ad-container--show .com-video_ad-VideoAdController__volume > .com-playback-Volume',
    };
  let observerS;

  /**
   * ページにイベントリスナーを追加
   */
  const addEventPage = () => {
    const id = document.querySelector(`.${sid}_Event`);
    if (!id) {
      log('addEventPage');
      /** @type {HTMLElement|null} */
      const inner = document.querySelector(selector.inner);
      if (inner) {
        inner.classList.add(`${sid}_Event`);
        inner.addEventListener('mousedown', checkMousedown, false);
        inner.addEventListener('wheel', checkMouseWheel, { passive: true });
      }
    }
  };

  /**
   * スタイルを追加
   * @param {string} s
   */
  const addStyle = (s) => {
    const disablePageScroll = `
html:has(.com-vod-VODResponsiveMainContent) {
  overflow-y: hidden;
  scrollbar-gutter: stable;
}
    `,
      init = `
.${sid}_Info {
  align-items: center;
  background-color: rgba(0, 0, 0, 0.4);
  border-radius: 4px;
  bottom: 70px;
  color: #fff;
  display: flex;
  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: 2260;
}
.${sid}_Info.vc_show {
  opacity: 0.8;
  visibility: visible;
}
.${sid}_Info.vc_hidden {
  opacity: 0;
  transition: opacity 0.5s ease-out, visibility 0.5s ease-out;
  visibility: hidden;
}
.${sid}_Info span:before,
.${sid}_Info span:after {
  box-sizing: content-box !important;
}
.vc_icon_before_hidden .${sid}_Volume2::before,
.vc_icon_after_hidden .${sid}_Volume2::after {
  visibility: hidden;
}
.${sid}_Info span::before,
.${sid}_Info span::after {
  content: '';
  display: block;
  position: absolute;
}
.${sid}_Volume1 {
  height: 20px;
  position: relative;
  width: 30px;
}
.${sid}_Volume1::before {
  background: #fff;
  height: 8px;
  left: 2px;
  top: 6px;
  width: 4px;
}
.${sid}_Volume1::after {
  border: 5px transparent solid;
  border-left-width: 0;
  border-right-color: #fff;
  height: 8px;
  left: 6px;
  top: 1px;
  width: 0;
}
.${sid}_Volume2,
.${sid}_Volume3 {
  position: absolute;
}
.${sid}_Volume2 {
  left: 8px;
  top: 5px;
}
.${sid}_Volume2::before,
.${sid}_Volume2::after {
  border: 2px solid transparent;
  border-right: 2px solid #fff;
}
.${sid}_Volume2::before {
  border-radius: 20px;
  height: 20px;
  left: -3px;
  top: -2px;
  width: 20px;
}
.${sid}_Volume2::after {
  border-radius: 10px;
  height: 15px;
  left: -2px;
  top: 1px;
  width: 15px;
}
.${sid}_Volume3 {
  left: 20px;
  top: 14px;
}
.${sid}_Volume3::before,
.${sid}_Volume3::after {
  background-color: #fff;
  height: 2px;
  width: 12px;
}
.${sid}_Volume3::before {
  transform: rotate(45deg);
}
.${sid}_Volume3::after {
  transform: rotate(135deg);
}
.${sid}_Volume4 {
  font-weight: bold;
  margin-left: 1ex;
}
    `,
      style = document.createElement('style');
    if (s === 'disablePageScroll') {
      style.textContent = disablePageScroll;
    } else if (s === 'init') {
      style.textContent = init;
    }
    style.id = `${sid}_style_${s}`;
    document.head.appendChild(style);
  };

  /**
   * 音量を変更できるか判別する
   * @returns {boolean}
   */
  const changeableVolume = () => {
    const vi = document.querySelector(selector.video);
    if (vi && !document.querySelector('.vjs-tech')) {
      flag.type = 2;
      return true;
    }
    flag.type = 0;
    return false;
  };

  /**
   * 動画の音をミュート・解除
   * @param {KeyboardEvent|MouseEvent} e
   * @param {boolean} f
   */
  const changeMute = (e, f) => {
    if (changeableVolume()) {
      const vi = returnVideo(),
        /** @type {HTMLButtonElement|null} */
        button = document.querySelector(selector.button);
      if (vi && ((e instanceof MouseEvent && e.button === 1 && f) || !f)) {
        if (button) button.click();
        if (vi.volume === 0) showInfo('');
        else showInfo(String(Math.floor(vi.volume * 100)));
      }
    }
  };

  /**
   * 音量スライダーの位置が動いたとき
   */
  const changeSlider = () => {
    const vi = returnVideo();
    if (vi) {
      if (vi.volume === 0) showInfo('');
      else showInfo(String(Math.floor(vi.volume * 100)));
    }
  };

  /**
   * 音量を変更する
   * @param {*} marker ボリュームマーカーの位置
   * @param {*} vol 音量の値
   */
  const changeVolume = (marker, vol) => {
    const full = document.querySelector(selector.fullscreen),
      info = full
        ? document.querySelector(`.${sid}_Info_Full`)
        : document.querySelector(`.${sid}_Info_Standard`);
    if (info) {
      vol = vol > 1 ? 1 : vol < 0 ? 0 : vol;
      vol = floor2(vol);
      if (vol > 0.66) {
        info.classList.remove('vc_icon_before_hidden');
        info.classList.remove('vc_icon_after_hidden');
      } else if (vol > 0.33) {
        info.classList.add('vc_icon_before_hidden');
        info.classList.remove('vc_icon_after_hidden');
      } else {
        info.classList.add('vc_icon_before_hidden');
        info.classList.add('vc_icon_after_hidden');
      }
      clearTimeout(interval.wheel);
      flag.wheel = true;
      interval.wheel = setTimeout(() => {
        flag.wheel = false;
        moveVolumeMarker(marker, 'mouseup');
      }, 150);
      moveVolumeMarker(marker, 'mousedown');
    }
  };

  /**
   * キーボードで音量を変更する
   * @param {number} a 音量の変更量
   */
  const changeVolumeKeyboard = (a) => {
    if (changeableVolume()) {
      const vi = returnVideo();
      flag.volume = true;
      changeVolume(a * 100, vi ? floor2(vi.volume) + a / -1 : 0);
    } else log('changeVolumeKeyboard: not changeableVolume');
  };

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

  /**
   * フルスクリーンの変更を検知して必要なら音量を表示する要素を追加する
   */
  const checkFullScreen = () => {
    const info = document.querySelector(`.${sid}_Info_Full`);
    hideInfo();
    if (!info) {
      log('checkFullScreen');
      createInfo('full');
    }
  };

  /**
   * キーボードのキーを押したとき
   * @param {KeyboardEvent} e
   */
  const checkKeyDown = (e) => {
    if (
      !(
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      )
    ) {
      if (!e.altKey && !e.ctrlKey && !e.metaKey) {
        const style = getComputedStyle(document.documentElement),
          tv = /^https:\/\/abema\.tv\/now-on-air\/.+$/.test(location.href)
            ? true
            : false;
        if (
          (e.key === 'ArrowUp' || e.key === 'ArrowDown') &&
          ((tv && !e.shiftKey) ||
            (!tv &&
              (e.shiftKey ||
                style.height === '0px' ||
                style.overflowY === 'hidden')))
        ) {
          e.stopPropagation();
          const vi = returnVideo();
          if (vi?.volume === 0 && !flag.volumeDownMuted) changeMute(e, false);
          else {
            if (e.key === 'ArrowUp') {
              changeVolumeKeyboard(-adjustKeyboard / 100);
            } else if (e.key === 'ArrowDown' && !flag.volumeDownMuted) {
              changeVolumeKeyboard(adjustKeyboard / 100);
            }
          }
          if (vi?.volume === 0) flag.volumeDownMuted = true;
          else flag.volumeDownMuted = false;
        }
      }
    }
  };

  /**
   * マウスのボタンを押したとき
   * @param {MouseEvent} e
   */
  const checkMousedown = (e) => {
    if (e.button === 1) {
      if (e.target instanceof HTMLElement) {
        const player = document.querySelector(selector.player);
        if (player?.contains(e.target)) {
          e.preventDefault();
          changeMute(e, true);
        }
      }
    }
  };

  /**
   * マウスホイールを回転操作したとき
   * @param {WheelEvent} e
   */
  const checkMouseWheel = (e) => {
    if (changeableVolume() && e.target instanceof Element) {
      const y = e.deltaMode > 0 ? Math.round(e.deltaY) * 100 : e.deltaY,
        full = document.querySelector(selector.fullscreen),
        tv = document.querySelector(selector.tv),
        vod = document.querySelector(selector.vod);
      flag.volume = false;
      if (
        (tv && (tv.contains(e.target) || !flag.scroll)) ||
        (vod &&
          ((vod.contains(e.target) && (full || e.shiftKey)) || !flag.scroll))
      ) {
        flag.volume = true;
      }
      if (
        !e.target
          .closest(selector.sliderContainer)
          ?.querySelector(selector.marker)
      ) {
        if (flag.volume) {
          const vi = returnVideo();
          if (vi && vi.volume === 0 && !flag.volumeDownMuted) {
            changeMute(e, false);
          } else if (!(flag.volumeDownMuted && e.deltaY > 0)) {
            changeVolume(
              e.deltaMode > 0
                ? Math.round(e.deltaY * adjustWheel)
                : (e.deltaY / 100) * adjustWheel,
              vi ? floor2(vi.volume) + y / -10000 : 0
            );
          }
          if (vi?.volume === 0) flag.volumeDownMuted = true;
          else flag.volumeDownMuted = false;
        }
      }
    } else log('checkMouseWheel: not changeableVolume');
  };

  /**
   * 音量ボタンにイベントリスナーが登録されていなければ登録する
   */
  const checkVolumeElementEventListener = () => {
    const eVolume = document.querySelectorAll(selector.volume);
    if (eVolume.length) {
      for (let i = 0; i < eVolume.length; i++) {
        if (!eVolume[i].classList.contains(`.${sid}_VolumeElement`)) {
          eVolume[i].classList.add(`${sid}_VolumeElement`);
          eVolume[i].addEventListener('mouseover', disablePageScroll);
          eVolume[i].addEventListener('mouseout', enablePageScroll);
        }
      }
    }
  };

  /**
   * 音量スライダーが監視されていなければ監視する
   */
  const checkVolumeSliderObserve = () => {
    const id = document.querySelector(`.${sid}_VolumeSlider`);
    if (!id) {
      log('checkVolumeSliderObserve');
      const eSlider = document.querySelector(selector.sliderHighlighter);
      if (eSlider) {
        eSlider.classList.add(`${sid}_VolumeSlider`);
        observerS.observe(eSlider, moConfig);
      } else log('checkVolumeSliderObserve: Not found element.');
    }
  };

  /**
   * 音量を表示する要素を作成
   * @param {string} s fullならフルスクリーン用
   */
  const createInfo = (s) => {
    const div = document.createElement('div'),
      full = document.querySelector(selector.fullscreen);
    div.classList.add(`${sid}_Info`);
    div.innerHTML = `
      <span class="${sid}_Volume1"></span>
      <span class="${sid}_Volume2"></span>
      <span class="${sid}_Volume3"></span>
      <span class="${sid}_Volume4"></span>
      `;
    if (s === 'full') {
      if (full && !full.classList.contains(`${sid}_Info_Full`)) {
        div.classList.add(`${sid}_Info_Full`);
        full.appendChild(div);
      }
    } else if (s === 'init') {
      if (!document.querySelector(`.${sid}_Info_Standard`)) {
        div.classList.add(`${sid}_Info_Standard`);
        document.body.appendChild(div);
      }
    }
  };

  /**
   * ページをスクロールできないようにする
   */
  const disablePageScroll = () => {
    if (flag.scroll) {
      if (!document.querySelector(selector.fullscreen)) {
        flag.scroll = false;
        addStyle('disablePageScroll');
      }
    }
  };

  /**
   * ページをスクロールできるようにする
   */
  const enablePageScroll = () => {
    if (!flag.scroll) {
      flag.scroll = true;
      removeStyle('disablePageScroll');
    }
  };

  /**
   * 小数点第3位以降を切り捨てた数値を返す
   * @param {number} n
   * @returns
   */
  const floor2 = (n) => Math.floor(n * 100) / 100;

  /**
   * 音量を表示する要素を隠す
   */
  const hideInfo = () => {
    const info = document.querySelectorAll(`.${sid}_Info`);
    for (let i = 0; i < info.length; i++) {
      info[i].classList.remove('vc_show');
      info[i].classList.add('vc_hidden');
    }
  };

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

  /**
   * デバッグ用ログ
   * @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);
      }
    }
  };

  /**
   * ボリュームスライダーのマーカーを動かして音量を変更する
   * @param {number} n ボリュームスライダーのマーカー位置
   * @param {string} t mouseupかmousedown
   */
  const moveVolumeMarker = (n, t) => {
    const slider = document.querySelector(selector.slider),
      marker = document.querySelector(selector.marker);
    if (n && slider && marker) {
      slider.dispatchEvent(
        new MouseEvent(t, {
          bubbles: true,
          cancelable: true,
          view: window,
          clientX: marker.getBoundingClientRect().x,
          clientY: marker.getBoundingClientRect().y + n + 5,
        })
      );
    }
  };

  /**
   * スタイルを削除する
   * @param {string} s スタイルの設定名
   */
  const removeStyle = (s) => {
    const e = document.getElementById(`${sid}_style_${s}`);
    if (e instanceof HTMLStyleElement) e.remove();
  };

  /**
   * video要素を返す
   * @returns {HTMLVideoElement|null}
   */
  const returnVideo = () => {
    if (flag.type === 2) {
      /** @type {HTMLVideoElement|null} */
      const vi = document.querySelector(selector.video);
      if (vi) return vi;
    }
    return 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 key = num(adjustKeyboard),
      wheel = num(adjustWheel);
    key = key > 20 ? 20 : key < 1 && key !== 0 ? 1 : key;
    wheel = wheel > 20 ? 20 : wheel < 1 && wheel !== 0 ? 1 : wheel;
    adjustKeyboard = ls.adjustKeyboard ? ls.adjustKeyboard : key ? key : 1;
    adjustWheel = ls.adjustWheel ? ls.adjustWheel : wheel ? wheel : 5;
    if (key && ls.adjustKeyboard !== key) {
      adjustKeyboard = key;
      ls.adjustKeyboard = key;
      saveLocalStorage();
    }
    if (wheel && ls.adjustWheel !== wheel) {
      adjustWheel = wheel;
      ls.adjustWheel = wheel;
      saveLocalStorage();
    }
  };

  /**
   * 現在の音量を表示
   * @param {string} s 表示する文字列
   */
  const showInfo = (s) => {
    const eFull = document.querySelector(`.${sid}_Info_Full`),
      eInfo = document.querySelector(`.${sid}_Info_Standard`),
      ele = document.querySelector(selector.fullscreen) ? eFull : eInfo;
    const eVol2 = ele?.querySelector(`.${sid}_Volume2`),
      eVol3 = ele?.querySelector(`.${sid}_Volume3`),
      eVol4 = ele?.querySelector(`.${sid}_Volume4`),
      vi = returnVideo();
    if (eVol4) eVol4.textContent = vi?.volume === 0 ? 'ミュート' : s ? s : '';
    if (eVol2 instanceof HTMLSpanElement && eVol3 instanceof HTMLSpanElement) {
      if (vi?.volume === 0) {
        eVol2.style.display = 'none';
        eVol3.style.display = 'block';
      } else {
        eVol2.style.display = 'block';
        eVol3.style.display = 'none';
      }
    }
    if (ele) {
      ele.classList.remove('vc_hidden');
      ele.classList.add('vc_show');
    }
    clearTimeout(interval.info);
    interval.info = setTimeout(() => {
      if (ele) {
        ele.classList.remove('vc_show');
        ele.classList.add('vc_hidden');
      }
    }, 1000);
  };

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

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

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

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