Greasy Fork

Greasy Fork is available in English.

AbemaTV Volume Control

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

目前为 2023-10-08 提交的版本。查看 最新版本

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

(() => {
  'use strict';

  const sid = 'VolumeControl',
    ls = JSON.parse(localStorage.getItem(sid) || '{}') || {},
    moConfig = { attributes: true, characterData: true },
    moConfig2 = { childList: true, subtree: true },
    flag = { mute: false, type: 0, vod: false, volume: false, wheel: false },
    interval = { info: 0, init: 0, video: 0, wheel: 0 },
    selector = {
      button: '.com-playback-Volume__icon-button',
      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,.com-vod-VODScreen-container,.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',
      sliderH:
        '.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,.com-live-event-LiveEventPlayerSectionLayout__player-area-inner--video',
      vodfull: 'div[class="c-vod-EpisodePlayerContainer-container"]',
      vodfull2: '.com-vod-VODRecommendedContentsContainerView__player > div',
    };
  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', changeVolumeWheel, { passive: true });
      }
    }
  };

  /**
   * 音量を変更できるか判別する
   * @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 {MouseEvent} e
   */
  const changeMute = (e) => {
    if (e.button === 1 && changeableVolume()) {
      const vi = returnVideo(),
        /** @type {HTMLButtonElement|null} */
        button = document.querySelector(selector.button);
      if (vi) {
        if (button) button.click();
        if (vi.muted) showInfo('');
        else showInfo(String(Math.floor(vi.volume * 100)));
      }
    }
  };

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

  /**
   * 音量を変更する
   * @param {*} marker ボリュームマーカーの位置
   * @param {*} vol 音量の値
   * @param {boolean} shift Shiftキーを押しているかどうか
   */
  const changeVolume = (marker, vol, shift) => {
    /*
      const info = document.getElementById('VolumeControl_Info'),
        vi = returnVideo(),
        floor2 = (n) => Math.floor(n * 100) / 100;
      let vol, marker;
      flag.vod = false;
      flag.volume = false;
      if (b) {
        flag.volume = true;
        vol = floor2(vi.volume) + a / -1;
        marker = a * 100;
      } else {
        const y = a.deltaMode > 0 ? Math.round(a.deltaY) * 100 : a.deltaY;
        marker = a.deltaMode > 0 ? Math.round(a.deltaY) : a.deltaY / 100;
        vol = floor2(vi.volume) + y / -10000;
        if (a.path?.length) {
          for (let i = 0; i < a.path.length; i++) {
            if (
              /tv-TVScreen__player|vod-EpisodePlayerContainer/.test(
                a.path[i].className
              )
            ) {
              if (/vod-EpisodePlayerContainer/.test(a.path[i].className)) {
                flag.vod = true;
              }
              flag.volume = true;
              break;
            }
          }
        }
      }
    */
    const floor2 = (n) => Math.floor(n * 100) / 100,
      info = document.getElementById(`${sid}_Info`),
      pl2 = document.querySelector(selector.vodfull2),
      full = document.querySelector(selector.vodfull)
        ? true
        : pl2 && getComputedStyle(pl2, '::backdrop').position === 'fixed'
        ? true
        : false;
    if (
      info &&
      flag.volume &&
      (!flag.vod || (flag.vod && (full || (!full && shift))))
    ) {
      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(),
        floor2 = (n) => Math.floor(n * 100) / 100;
      flag.volume = true;
      changeVolume(a * 100, vi ? floor2(vi.volume) + a / -1 : 0, false);
    } else log('changeVolumeKeyboard: not changeableVolume');
  };

  /**
   * マウスホイールで音量を変更する
   * @param {WheelEvent} e
   */
  const changeVolumeWheel = (e) => {
    if (changeableVolume() && e.target instanceof HTMLElement) {
      const y = e.deltaMode > 0 ? Math.round(e.deltaY) * 100 : e.deltaY,
        vi = returnVideo(),
        floor2 = (n) => Math.floor(n * 100) / 100,
        tv = document.querySelector(selector.tv),
        vod = document.querySelector(selector.vod);
      flag.vod = false;
      flag.volume = false;
      if (tv?.contains(e.target)) {
        flag.volume = true;
      } else if (vod?.contains(e.target)) {
        flag.vod = true;
        flag.volume = true;
      }
      changeVolume(
        e.deltaMode > 0 ? Math.round(e.deltaY) : e.deltaY / 100,
        vi ? floor2(vi.volume) + y / -10000 : 0,
        e.shiftKey
      );
    } else log('changeVolumeWheel: not changeableVolume');
  };

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

  /**
   * キーボードのキーを押したとき
   * @param {KeyboardEvent} e
   */
  const checkKeyDown = (e) => {
    if (
      !(
        e.target instanceof HTMLInputElement ||
        e.target instanceof HTMLTextAreaElement
      )
    ) {
      if (!e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
        if (e.key === 'ArrowUp') {
          e.stopPropagation();
          changeVolumeKeyboard(-0.05);
        } else if (e.key === 'ArrowDown') {
          e.stopPropagation();
          changeVolumeKeyboard(0.05);
        }
      }
    }
  };

  /**
   * マウスのボタンを押したとき
   * @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);
        }
      }
    }
  };

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

  /**
   * 音量を表示する要素を作成
   */
  const createInfo = () => {
    const css = `
      #${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;
      }
      `,
      div = document.createElement('div'),
      style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
    div.id = `${sid}_Info`;
    div.innerHTML = `
      <span id="${sid}_Volume1"></span>
      <span id="${sid}_Volume2"></span>
      <span id="${sid}_Volume3"></span>
      <span id="${sid}_Volume4"></span>
      `;
    document.body.appendChild(div);
  };

  /**
   * ページを開いたときに1度だけ実行
   */
  const init = () => {
    log('init');
    observerS = new MutationObserver(changeSlider);
    waitShowVideo();
    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);
      }
    }
  };

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

  /**
   * 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;
  };

  /**
   * 現在の音量を表示
   * @param {string} s 表示する文字列
   */
  const showInfo = (s) => {
    const eInfo = document.getElementById(`${sid}_Info`),
      eVol2 = document.getElementById(`${sid}_Volume2`),
      eVol3 = document.getElementById(`${sid}_Volume3`),
      eVol4 = document.getElementById(`${sid}_Volume4`),
      vi = returnVideo();
    if (eVol4) eVol4.textContent = vi?.muted ? 'ミュート' : s ? s : '';
    if (eVol2 && eVol3) {
      if (vi?.muted) {
        eVol2.style.display = 'none';
        eVol3.style.display = 'block';
      } else {
        eVol2.style.display = 'block';
        eVol3.style.display = 'none';
      }
    }
    if (eInfo) {
      eInfo.classList.remove('vc_hidden');
      eInfo.classList.add('vc_show');
    }
    clearTimeout(interval.info);
    interval.info = setTimeout(() => {
      if (eInfo) {
        eInfo.classList.remove('vc_show');
        eInfo.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('keydown', checkKeyDown, true);
    const main = document.querySelector('main');
    if (main) observerC.observe(main, moConfig2);
    else log('startFirstObserve: Not found element.', 'error');
    checkVolumeSliderObserve();
  };

  /**
   * 動画が表示されるのを待つ
   */
  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.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|live-event)\/.+$/.test(
        location.href
      )
    ) {
      clearInterval(interval.init);
      init();
    }
  }, 1000);
})();