Greasy Fork is available in English.

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