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