// ==UserScript== // @name AbemaTV Volume Control // @namespace https://greasyfork.org/ja/scripts/26397 // @version 17 // @description ABEMA視聴中にキーボードやマウスホイールで音量を調整します。 // @match https://abema.tv/ // @match https://abema.tv/* // @grant none // @license MIT License // @downloadURL none // ==/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 = ` `; 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); })();