// ==UserScript== // @name AbemaTV Shortcut Key Controller // @namespace knoa.jp // @description AbemaTVでショートカットキーによる操作を可能にします。キー割り当てはYouTube準拠。 // @include https://abema.tv/* // @version 2.3.1 // @grant none // @downloadURL none // ==/UserScript== // console.log('AbemaTV? => hireMe()'); (function(){ const SCRIPTNAME = 'ShortcutKeyController'; const DEBUG = false;/* [update] マウスホイールによる音量調整幅をゆるやかに。ミュートも画面表示。ほか軽微な修正。 [to do] これもdiv.panelsに入れないとヘルプでスクロールバーが出ちゃう(Firefoxのみ?) [possible] Escapeでcloser? Keyboardで音量、スクロール文字の透明度? [requests] 画質変更・・・このスクリプトでやることではない気もするが */ if(window === top && console.time) console.time(SCRIPTNAME); const DUMMY = document.createElement('span'); let site = { elements: { /* 共通 */ fullscreenButton: function(){let node = $('use[*|href*="mini_screen.svg"]') || $('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;}, timetableButton: function(){let node = $('button[data-selector="TimetableViewer-button"]'); 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'); 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 loading = $('img[src="/images/misc/feed_loading.gif"]'); if(!loading) return; let button = loading.parentNode.parentNode.parentNode.parentNode.querySelectorAll('div > button')[1];/*アベマの構造にすごく依存する*/ return button; }, timetableHeaders: function(){let nodes = $$('#TimetableViewer-timetable-panel .channels > li > header'); return nodes ? nodes : DUMMY;}, /* タイムシフト */ stopper: function(){let node = $('use[*|href^="/images/icons/playback.svg"]'); return node ? node.parentNode.parentNode.nextElementSibling : 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_30.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; }, getCurrentVolume: function(){ let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect(); if(slider.dataset.volume === undefined){ return Math.round(100 * parseInt(slider.firstElementChild.style.height) / rect.height); }else{ return parseInt(slider.dataset.volume); } }, /* 整数による0-100の音量調整に対応する */ modifyVolume: function(e){ let slider = site.elements.volumeSlider(), rect = slider.getBoundingClientRect(), volume = site.getCurrentVolume(); switch(e.deltaMode){ case(WheelEvent.DOM_DELTA_PIXEL):/*ヌルヌル*/ switch(true){ case(e.deltaY < -20): volume -= e.deltaY/20; break; case(e.deltaY < 0): volume += 1; break;/*最低1*/ case(e.deltaY < 20): volume -= 1; break;/*最低1*/ default: volume -= e.deltaY/20; break; } break; case(WheelEvent.DOM_DELTA_LINE):/*カクカク*/ default: if(e.deltaY < 0){ switch(true){ case(volume < 10): volume += 1; break;/* 1単位*/ default: volume += 5; break;/* 5単位*/ } }else{ switch(true){ case(volume <= 10): volume -= 1; break;/* 1単位*/ default: volume -= 5; break;/* 5単位*/ } } break; } volume = Math.min(Math.max(Math.round(volume), 0), 100);/*四捨五入して0-100に収める*/ slider.dispatchEvent(new MouseEvent('mousedown'/*clickだと効かない*/, { clientX: rect.x + (rect.width/2), clientY: rect.y + (rect.height * (1 - volume/100)) + .5/*ゼロの時は確実にゼロにする*/, bubbles: true, })); slider.dataset.volume = volume; core.indicateVolume(volume); }, 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 = {}, indicatorTimer; let core = { initialize: function(){ window.addEventListener('wheel', site.assign, true); window.addEventListener('keydown', site.assign, true); core.appendVolumeIndicator(); core.addStyle(); }, /* 音量表示の準備 */ appendVolumeIndicator: function(e){ elements.indicator = createElement(core.html.volumeIndicator()); document.body.appendChild(elements.indicator); }, /* 音量表示 */ indicateVolume: function(volume){ elements.indicator.textContent = volume; elements.indicator.classList.add('active'); clearTimeout(indicatorTimer); indicatorTimer = setTimeout(function(){ elements.indicator.classList.remove('active'); }, 1000); }, /* リアルタイム */ realtime: function(e){ switch(true){ /* 音量 */ case(e.type === 'wheel' && Math.abs(e.deltaX) < Math.abs(e.deltaY)/*縦ホイールのみ*/): /* あらゆる場所でのイベントを拾ってwindow.addEventListenerで一括処理する代償をここで支払う */ let parents = [site.elements.closer(), site.elements.footer(), ...site.elements.timetableHeaders()]; for(let target = e.target; target; target = target.parentNode){ if(parents.includes(target)){ site.modifyVolume(e); return e.preventDefault(); } } return; /* コメント入力欄フォーカスを外す */ case(e.key === 'Escape'): if(document.activeElement === site.elements.commentTextarea()){ document.activeElement.blur(); return e.preventDefault(); } break; /* 以下、テキスト入力中は反応しない */ case(['input', 'textarea'].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 === 't'): site.elements.timetableButton().click(); return e.preventDefault(); /* 番組情報 */ case(e.key === 'i'): site.elements.programButton().click(); return e.preventDefault(); /* 10秒戻る(20秒かけて追いつく) */ case(e.key === 'j'): case(e.key === 'ArrowLeft'): const REWIND = 10, CATCHUP = 1.5; let videos = document.querySelectorAll('video[src]'); for(let i = 0, video; video = videos[i]; i++){ if(video.paused || video.rewinded) continue; if(video.currentTime > 1e9) continue;/*currentTimeがunixtimeならmpeg-dashで巻き戻し不可*/ let rewind = Math.min(REWIND, video.currentTime); video.rewinded = true; video.currentTime = video.currentTime - rewind; video.playbackRate = CATCHUP; setTimeout(function(){ video.rewinded = false; video.playbackRate = 1; }, (rewind / (CATCHUP - 1))*1000); } return e.preventDefault(); /* フルスクリーン */ case(e.key === 'f'): site.elements.fullscreenButton().click(); return e.preventDefault(); /* ミュート */ case(e.key === 'm'): site.elements.muteButton().click(); if(elements.indicator.textContent !== 'mute') core.indicateVolume('mute'); else core.indicateVolume(site.getCurrentVolume()); 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(); } return; /* 以下、テキスト入力中は反応しない */ case(['input', 'textarea'].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(); /* 30秒進む */ 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){ 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(['input', 'textarea'].includes(document.activeElement.localName)) return; 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: { volumeIndicator: (type) => `
`, help: (type) => `