// ==UserScript==
// @name AbemaTV Shortcut Key Controller
// @namespace knoa.jp
// @description AbemaTVでショートカットキーによる操作を可能にします。キーアサインはYouTube準拠。
// @include https://abema.tv/*
// @version 2.1.4
// @grant none
// ==/UserScript==
// console.log('AbemaTV? => hireMe()');
(function(){
const SCRIPTNAME = 'ShortcutKeyController';
const DEBUG = true;/*
[update]
Edgeに対応しました。
[to do]
ヘルプにリアルタイムディスエイブルなど
リアルタイムでフルスクリーン解除怪しい
パネル上でもマウスホイールしたい?
ホワイトリストじゃなくてscrollableかどうかで判定できるのでは>>むずかしそう
*/
if(window === top && console.time) console.time(SCRIPTNAME);
const DUMMY = document.body;
let site = {
elements: {
/* 共通 */
fullscreenButton: function(){let node = $('use[*|href*="_screen.svg"]'/*タイムシフトのbuttonにaria-labelがないので*/); return node ? node.parentNode.parentNode : DUMMY;},
volumeSlider: function(){let node = $('button[aria-label="音声オンオフ切り替え"]'); return node ? node.previousSibling.firstElementChild.firstElementChild : DUMMY;},
muteButton: function(){let node = $('button[aria-label="音声オンオフ切り替え"]'); return node ? node : DUMMY;},
/* リアルタイム */
channelButton: function(){let node = $('button[aria-label="放送中の裏番組"]'); return node ? node : DUMMY;},
commentButton: function(){let node = $('use[*|href^="/images/icons/comment.svg"]'); return node ? node.parentNode.parentNode : DUMMY;},
programButton: function(){let node = $('button[aria-label^="フルスクリーン"] + div + div > div > div'); return node ? node : DUMMY;},
commentTextarea: function(){let node = $('textarea[placeholder="コメントを入力"]'); return node ? node : DUMMY;},
footer: function(){let node = $('button[aria-label^="フルスクリーン"]'); return node ? node.parentNode.parentNode : DUMMY;},
closer: function(){let node = $('form:not([role="search"])'); return node ? node.parentNode.parentNode.nextElementSibling : DUMMY;},
/* タイムシフト */
stopper: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode.parentNode.previousElementSibling : DUMMY;},
playButton: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode.previousElementSibling : DUMMY;},
rewindButton: function(){let node = $('use[*|href^="/images/icons/rewind_10.svg"]'); return node ? node.parentNode.parentNode : DUMMY;},
advancesButton: function(){let node = $('use[*|href^="/images/icons/advances_10.svg"]'); return node ? node.parentNode.parentNode : DUMMY;},
},
isCommentPaneHidden: function(){
let form = $('form:not([role="search"])');
return (form) ? (form.parentNode.parentNode.getAttribute('aria-hidden') === 'true') : false;
},
modifyVolume: function(e){
let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect();
let volume = parseInt(slider.firstElementChild.style.height) / rect.height;
switch(e.deltaMode){
case(WheelEvent.DOM_DELTA_PIXEL):/*ヌルヌル*/
volume += -(e.deltaY/1000);
break;
case(WheelEvent.DOM_DELTA_LINE):/*カクカク*/
default:
volume += (0 < e.deltaY) ? -(1/10) : (1/10);
break;
}
slider.dispatchEvent(new MouseEvent('mousedown', {
clientX: rect.x + (rect.width/2),
clientY: rect.y + (rect.height * (1 - volume)),
bubbles: true,
}));
},
assign: function(e){
switch(true){
case(location.href.startsWith('https://abema.tv/now-on-air/')):
return core.realtime(e);
case(location.href.startsWith('https://abema.tv/channels/')):
case(location.href.startsWith('https://abema.tv/video/watch/')):
case(location.href.startsWith('https://abema.tv/video/episode/')):
return core.timeshift(e);
}
},
};
let globals = {}, elements = {};
let core = {
initialize: function(){
window.addEventListener('wheel', site.assign, true);
window.addEventListener('keydown', site.assign, true);
},
/* リアルタイム */
realtime: function(e){
switch(true){
/* 音量 */
case(e.type === 'wheel'):
/* あらゆる場所でのイベントを拾ってwindow.addEventListenerで一括処理する代償をここで支払う */
for(let target = e.target; target; target = target.parentNode){
if([site.elements.closer(), site.elements.footer()].includes(target)){
site.modifyVolume(e);
return e.preventDefault();
}
}
/* テキスト入力中は反応しない */
case(['input', 'textarea', 'button'].includes(document.activeElement.localName)):
return;
/* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
return;
/* コメント入力欄フォーカス */
case(e.key == 'k'):
case(e.key == ' '):
case(e.key == 'Enter'):
/* コメント欄が表示されていなければあらかじめ表示しておく */
if(site.isCommentPaneHidden()) site.elements.commentButton().click();
site.elements.commentTextarea().focus();
return e.preventDefault();
/* コメント */
case(e.key == 'c'):
if(site.isCommentPaneHidden()) site.elements.commentButton().click();
else site.elements.closer().click();
return e.preventDefault();
/* 裏番組一覧 */
case(e.key == 'n'):
site.elements.channelButton().click();
return e.preventDefault();
/* 番組情報 */
case(e.key == 'i'):
site.elements.programButton().click();
return e.preventDefault();
/* フルスクリーン */
case(e.key == 'f'):
site.elements.fullscreenButton().click();
return e.preventDefault();
/* ミュート */
case(e.key == 'm'):
site.elements.muteButton().click();
return e.preventDefault();
/* ヘルプ */
case(e.key == 'h'):
case(e.key == '/'):
core.toggleHelp('realtime');
return e.preventDefault();
}
},
/* タイムシフト */
timeshift: function(e){
switch(true){
/* 音量 */
case(e.type === 'wheel'):
if(e.target === site.elements.stopper()){
site.modifyVolume(e);
return e.preventDefault();
}
/* テキスト入力中は反応しない */
case(['input', 'textarea', 'button'].includes(document.activeElement.localName)):
return;
/* Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
return;
/* 再生・停止トグル */
case(e.key == 'k'):
case(e.key == ' '):
case(e.key == 'Enter'):
site.elements.playButton().click();
return e.preventDefault();
/* 10秒戻る */
case(e.key == 'j'):
case(e.key == 'ArrowLeft'):
site.elements.rewindButton().click();
return e.preventDefault();
/* 10秒進む */
case(e.key == 'l'):
case(e.key == 'ArrowRight'):
site.elements.advancesButton().click();
return e.preventDefault();
/* フルスクリーン */
case(e.key == 'f'):
site.elements.fullscreenButton().click();
return e.preventDefault();
/* ミュート */
case(e.key == 'm'):
site.elements.muteButton().click();
return e.preventDefault();
/* ヘルプ */
case(e.key == 'h'):
case(e.key == '/'):
core.toggleHelp('timeshift');
return e.preventDefault();
}
},
toggleHelp: function(type){
core.panel.toggle('help', core.createHelp.bind(null, type));
},
createHelp: function(type){
core.addStyle();
elements.help = createElement(core.html.help(type));
elements.help.querySelector('button.ok').addEventListener('click', core.panel.close.bind(null, 'help'));
core.panel.open('help');
},
/* パネル共通 */
panel: {
open: function(key){
elements[key].classList.add('hidden');
document.body.appendChild(elements[key]);
animate(function(){
elements[key].classList.remove('hidden');
});
if(!globals.listeningKeypress){
globals.listeningKeypress = true;
window.addEventListener('keypress', function(e){
if(elements[key] && e.key === 'Escape') core.panel.close(key);
});
}
},
close: function(key){
elements[key].classList.add('hidden');
elements[key].addEventListener('transitionend', function(){
if(!elements[key]) return;
document.body.removeChild(elements[key]);
elements[key] = null;
}, {once: true});
},
toggle: function(key, create){
(!elements[key]) ? create() : core.panel.close(key);
},
},
addStyle: function(){
let style = createElement(core.html.style());
document.head.appendChild(style);
if(elements.style && elements.style.isConnected) document.head.removeChild(elements.style);
elements.style = style;
},
html: {
help: (type) => `
<div class="${SCRIPTNAME} panel" id="${SCRIPTNAME}-help">
<h1>${SCRIPTNAME} ヘルプ</h1>
<h2>共通:</h2>
<dl>
<dt><kbd>[H]</kbd><kbd>[/]</kbd></dt><dd>ヘルプ表示 ([H]elp)</dd>
<dt><kbd>[F]</kbd></dt><dd>フルスクリーン ([F]ullscreen)</dd>
<dt><kbd>[M]</kbd></dt><dd>ミュート ([M]ute)</dd>
<dt><kbd>[マウスホイール]</kbd></dt><dd>音量調整</dd>
</dl>
<h2${(type === 'realtime') ? '' : ' class="disabled"'}>リアルタイム放送:</h2>
<dl>
<dt><kbd>[K]</kbd><kbd>[Space]</kbd><kbd>[Enter]</kbd></dt><dd>コメント入力欄フォーカス</dd>
<dt><kbd>[C]</kbd></dt><dd>コメント表示 ([C]omment)</dd>
<dt><kbd>[N]</kbd></dt><dd>裏番組一覧 ([N]ow on air)</dd>
<dt><kbd>[I]</kbd></dt><dd>番組情報 ([I]nformation)</dd>
</dl>
<h2${(type === 'timeshift') ? '' : ' class="disabled"'}>タイムシフト放送:</h2>
<dl>
<dt><kbd>[K]</kbd><kbd>[Space]</kbd><kbd>[Enter]</kbd></dt><dd>再生・停止</dd>
<dt><kbd>[J]</kbd><kbd>[←]</kbd></dt><dd>10秒戻る</dd>
<dt><kbd>[L]</kbd><kbd>[→]</kbd></dt><dd>10秒進む</dd>
</dl>
<p class="buttons"><button class="ok primary">OK</button></p>
</div>
`,
style: () => `
<style>
/* パネル共通 */
body{
overflow: hidden;
}
.${SCRIPTNAME}.panel{
position: absolute;
width: 360px;
max-height: 100%;/*小さなウィンドウに対応*/
overflow: auto;
left: 50%;
bottom: 50%;
transform: translate(-50%, 50%);
z-index: 100;
background: rgba(0,0,0,.75);
transition: 500ms ease;
padding: 5px 0;
}
.${SCRIPTNAME}.panel.hidden{
bottom: 0;
transform: translate(-50%, 100%) !important;
}
.${SCRIPTNAME} h1,
.${SCRIPTNAME} h2,
.${SCRIPTNAME} legend,
.${SCRIPTNAME} dl,
.${SCRIPTNAME} code,
.${SCRIPTNAME} p{
color: rgba(255,255,255,1);
font-size: 14px;
padding: 2px 10px;
line-height:20px;
}
.${SCRIPTNAME} header{
display: flex;
}
.${SCRIPTNAME} header h1{
flex: 1;
}
.${SCRIPTNAME}.panel > p.buttons{
text-align: right;
padding: 5px 10px;
}
.${SCRIPTNAME}.panel > p.buttons button{
width: 120px;
padding: 5px 10px;
margin-left: 10px;
border-radius: 5px;
color: rgba(255,255,255,1);
background: rgba(64,64,64,1);
border: 1px solid rgba(255,255,255,1);
}
.${SCRIPTNAME}.panel > p.buttons button.primary{
font-weight: bold;
background: rgba(0,0,0,1);
}
.${SCRIPTNAME}.panel > p.buttons button:hover,
.${SCRIPTNAME}.panel > p.buttons button:focus{
background: rgba(128,128,128,.75);
}
${SCRIPTNAME} .template{
display: none !important;
}
/* ヘルプパネル */
#${SCRIPTNAME}-help{
width: 400px;
}
#${SCRIPTNAME}-help dl{
display: flex;
flex-wrap: wrap;
}
#${SCRIPTNAME}-help dl dt{
width: 160px;
margin: 2.5px 10px 2.5px 0;
background: rgba(0,0,0,.5);
border-radius: 5px;
}
#${SCRIPTNAME}-help dl dt kbd{
margin-left: 5px;
}
#${SCRIPTNAME}-help dl dd{
width: 210px;
margin: 2.5px 0;
}
#${SCRIPTNAME}-help h2.disabled,
#${SCRIPTNAME}-help h2.disabled + dl{
opacity: .5;
}
</style>
`,
},
};
let $ = function(s){return document.querySelector(s)};
let animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
let createElement = function(html){
let outer = document.createElement('div');
outer.innerHTML = html;
return outer.firstElementChild;
};
let log = (DEBUG) ? console.log.bind(null, SCRIPTNAME + ':') : function(){};
core.initialize();
if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();