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